feat: File upload(plain text, pdf, image) #34

* support file upload

* preview pdf
This commit is contained in:
きむそん
2025-10-26 20:12:45 +09:00
committed by GitHub
parent a714622665
commit 51280f5bf8
25 changed files with 716 additions and 98 deletions

View File

@@ -47,6 +47,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.23",
"@anthropic-ai/claude-code": "^2.0.24",
"@anthropic-ai/sdk": "^0.67.0",
"@effect/platform": "^0.92.1",
"@effect/platform-node": "^0.98.4",
"@hono/node-server": "^1.19.5",

32
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@anthropic-ai/claude-code':
specifier: ^2.0.24
version: 2.0.24
'@anthropic-ai/sdk':
specifier: ^0.67.0
version: 0.67.0(zod@4.1.12)
'@effect/platform':
specifier: ^0.92.1
version: 0.92.1(effect@3.18.4)
@@ -231,6 +234,15 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
'@anthropic-ai/sdk@0.67.0':
resolution: {integrity: sha512-Buxbf6jYJ+pPtfCgXe1pcFtZmdXPrbdqhBjiscFt9irS1G0hCsmR/fPA+DwKTk4GPjqeNnnCYNecXH6uVZ4G/A==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -3210,6 +3222,10 @@ packages:
resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==}
engines: {node: ^18.17.0 || >=20.5.0}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@@ -4280,6 +4296,9 @@ packages:
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -4611,6 +4630,12 @@ snapshots:
'@img/sharp-linux-x64': 0.33.5
'@img/sharp-win32-x64': 0.33.5
'@anthropic-ai/sdk@0.67.0(zod@4.1.12)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.1.12
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
@@ -7544,6 +7569,11 @@ snapshots:
json-parse-even-better-errors@4.0.0: {}
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.28.4
ts-algebra: 2.0.0
json-schema-traverse@1.0.0: {}
json5@2.2.3: {}
@@ -8863,6 +8893,8 @@ snapshots:
trough@2.2.0: {}
ts-algebra@2.0.0: {}
tslib@2.8.1: {}
tsx@4.20.6:

View File

@@ -2,8 +2,10 @@ import { Trans, useLingui } from "@lingui/react";
import {
AlertCircleIcon,
LoaderIcon,
PaperclipIcon,
SendIcon,
SparklesIcon,
XIcon,
} from "lucide-react";
import { type FC, useCallback, useId, useRef, useState } from "react";
import { Button } from "../../../../../components/ui/button";
@@ -11,11 +13,18 @@ import { Textarea } from "../../../../../components/ui/textarea";
import { useConfig } from "../../../../hooks/useConfig";
import type { CommandCompletionRef } from "./CommandCompletion";
import type { FileCompletionRef } from "./FileCompletion";
import type { DocumentBlock, ImageBlock } from "./fileUtils";
import { InlineCompletion } from "./InlineCompletion";
export interface MessageInput {
text: string;
images?: ImageBlock[];
documents?: DocumentBlock[];
}
export interface ChatInputProps {
projectId: string;
onSubmit: (message: string) => Promise<void>;
onSubmit: (input: MessageInput) => Promise<void>;
isPending: boolean;
error?: Error | null;
placeholder: string;
@@ -40,6 +49,9 @@ export const ChatInput: FC<ChatInputProps> = ({
}) => {
const { i18n } = useLingui();
const [message, setMessage] = useState("");
const [attachedFiles, setAttachedFiles] = useState<
Array<{ file: File; id: string }>
>([]);
const [cursorPosition, setCursorPosition] = useState<{
relative: { top: number; left: number };
absolute: { top: number; left: number };
@@ -47,15 +59,63 @@ export const ChatInput: FC<ChatInputProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const commandCompletionRef = useRef<CommandCompletionRef>(null);
const fileCompletionRef = useRef<FileCompletionRef>(null);
const helpId = useId();
const { config } = useConfig();
const handleSubmit = async () => {
if (!message.trim()) return;
await onSubmit(message.trim());
if (!message.trim() && attachedFiles.length === 0) return;
const { processFile } = await import("./fileUtils");
const images: ImageBlock[] = [];
const documents: DocumentBlock[] = [];
let additionalText = "";
for (const { file } of attachedFiles) {
const result = await processFile(file);
if (result.type === "text") {
additionalText += `\n\nFile: ${file.name}\n${result.content}`;
} else if (result.type === "image") {
images.push(result.block);
} else if (result.type === "document") {
documents.push(result.block);
}
}
const finalText = message.trim() + additionalText;
await onSubmit({
text: finalText,
images: images.length > 0 ? images : undefined,
documents: documents.length > 0 ? documents : undefined,
});
setMessage("");
setAttachedFiles([]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const newFiles = Array.from(files).map((file) => ({
file,
id: `${file.name}-${Date.now()}-${Math.random()}`,
}));
setAttachedFiles((prev) => [...prev, ...newFiles]);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleRemoveFile = (id: string) => {
setAttachedFiles((prev) => prev.filter((f) => f.id !== id));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -159,7 +219,7 @@ export const ChatInput: FC<ChatInputProps> = ({
textareaRef.current?.focus();
};
const handleFileSelect = (filePath: string) => {
const handleFilePathSelect = (filePath: string) => {
setMessage(filePath);
textareaRef.current?.focus();
};
@@ -216,8 +276,50 @@ export const ChatInput: FC<ChatInputProps> = ({
/>
</div>
{attachedFiles.length > 0 && (
<div className="px-5 py-3 flex flex-wrap gap-2 border-t border-border/40">
{attachedFiles.map(({ file, id }) => (
<div
key={id}
className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-lg text-sm"
>
<span className="truncate max-w-[200px]">{file.name}</span>
<button
type="button"
onClick={() => handleRemoveFile(id)}
className="text-muted-foreground hover:text-foreground transition-colors"
disabled={isPending}
>
<XIcon className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between gap-3 px-5 py-3 bg-muted/30 border-t border-border/40">
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,text/plain"
onChange={handleFileSelect}
className="hidden"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isPending || disabled}
className="gap-1.5"
>
<PaperclipIcon className="w-4 h-4" />
<span className="text-xs">
<Trans id="chat.attach_file" message="Attach" />
</span>
</Button>
<span
className="text-xs font-medium text-muted-foreground/80"
id={helpId}
@@ -238,7 +340,11 @@ export const ChatInput: FC<ChatInputProps> = ({
<Button
onClick={handleSubmit}
disabled={!message.trim() || isPending || disabled}
disabled={
(!message.trim() && attachedFiles.length === 0) ||
isPending ||
disabled
}
size={buttonSize}
className="gap-2 transition-all duration-200 hover:shadow-md hover:scale-105 active:scale-95 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 disabled:from-muted disabled:to-muted"
>
@@ -267,7 +373,7 @@ export const ChatInput: FC<ChatInputProps> = ({
commandCompletionRef={commandCompletionRef}
fileCompletionRef={fileCompletionRef}
handleCommandSelect={handleCommandSelect}
handleFileSelect={handleFileSelect}
handleFileSelect={handleFilePathSelect}
cursorPosition={cursorPosition}
/>
</div>

View File

@@ -0,0 +1,148 @@
/**
* File utilities for file upload and encoding
*/
export type FileType = "text" | "image" | "pdf";
export type ImageBlock = {
type: "image";
source: {
type: "base64";
media_type: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
data: string;
};
};
export type DocumentBlock = {
type: "document";
source: {
type: "base64";
media_type: "application/pdf";
data: string;
};
};
/**
* Determine file type based on MIME type
*/
export const determineFileType = (mimeType: string): FileType => {
if (mimeType.startsWith("image/")) {
return "image";
}
if (mimeType === "application/pdf") {
return "pdf";
}
return "text";
};
/**
* Check if MIME type is supported
*/
export const isSupportedMimeType = (mimeType: string): boolean => {
const supportedImageTypes = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
];
const supportedDocumentTypes = ["application/pdf"];
const supportedTextTypes = ["text/plain"];
return (
supportedImageTypes.includes(mimeType) ||
supportedDocumentTypes.includes(mimeType) ||
supportedTextTypes.includes(mimeType)
);
};
/**
* Convert File to base64 encoded string (without data URL prefix)
*/
export const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
// Remove data URL prefix (e.g., "data:image/png;base64,")
const base64 = result.split(",")[1];
resolve(base64 ?? "");
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => {
reject(new Error("Failed to read file"));
};
reader.readAsDataURL(file);
});
};
/**
* Convert File to plain text
*/
export const fileToText = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result === "string") {
resolve(result);
} else {
reject(new Error("Failed to read file as text"));
}
};
reader.onerror = () => {
reject(new Error("Failed to read file"));
};
reader.readAsText(file);
});
};
/**
* Process a file and return appropriate block structure
*/
export const processFile = async (
file: File,
): Promise<
| { type: "text"; content: string }
| { type: "image"; block: ImageBlock }
| { type: "document"; block: DocumentBlock }
> => {
const fileType = determineFileType(file.type);
if (fileType === "text") {
const content = await fileToText(file);
return { type: "text", content };
}
const base64Data = await fileToBase64(file);
if (fileType === "image") {
const mediaType = file.type as ImageBlock["source"]["media_type"];
return {
type: "image",
block: {
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64Data,
},
},
};
}
// PDF
return {
type: "document",
block: {
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: base64Data,
},
},
};
};

View File

@@ -1,4 +1,4 @@
export type { ChatInputProps } from "./ChatInput";
export type { ChatInputProps, MessageInput } from "./ChatInput";
export { ChatInput } from "./ChatInput";
export type { CommandCompletionRef } from "./CommandCompletion";
export { CommandCompletion } from "./CommandCompletion";

View File

@@ -1,6 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { honoClient } from "../../../../../lib/api/client";
import type { MessageInput } from "./ChatInput";
export const useCreateSessionProcessMutation = (
projectId: string,
@@ -10,7 +11,7 @@ export const useCreateSessionProcessMutation = (
return useMutation({
mutationFn: async (options: {
message: string;
input: MessageInput;
baseSessionId?: string;
}) => {
const response = await honoClient.api.cc["session-processes"].$post(
@@ -18,7 +19,7 @@ export const useCreateSessionProcessMutation = (
json: {
projectId,
baseSessionId: options.baseSessionId,
message: options.message,
input: options.input,
},
},
{
@@ -53,7 +54,7 @@ export const useContinueSessionProcessMutation = (
) => {
return useMutation({
mutationFn: async (options: {
message: string;
input: MessageInput;
sessionProcessId: string;
}) => {
const response = await honoClient.api.cc["session-processes"][
@@ -64,7 +65,7 @@ export const useContinueSessionProcessMutation = (
json: {
projectId: projectId,
baseSessionId: baseSessionId,
continueMessage: options.message,
input: options.input,
},
},
{

View File

@@ -1,7 +1,11 @@
import { Trans, useLingui } from "@lingui/react";
import type { FC } from "react";
import { useConfig } from "../../../../hooks/useConfig";
import { ChatInput, useCreateSessionProcessMutation } from "../chatForm";
import {
ChatInput,
type MessageInput,
useCreateSessionProcessMutation,
} from "../chatForm";
export const NewChat: FC<{
projectId: string;
@@ -14,8 +18,8 @@ export const NewChat: FC<{
);
const { config } = useConfig();
const handleSubmit = async (message: string) => {
await createSessionProcess.mutateAsync({ message });
const handleSubmit = async (input: MessageInput) => {
await createSessionProcess.mutateAsync({ input });
};
const getPlaceholder = () => {

View File

@@ -1,14 +1,23 @@
import { Trans } from "@lingui/react";
import { AlertCircle, Image as ImageIcon } from "lucide-react";
import {
AlertCircle,
ChevronDown,
FileText,
Image as ImageIcon,
} from "lucide-react";
import type { FC } from "react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { UserMessageContent } from "@/lib/conversation-schema/message/UserMessageSchema";
import { UserTextContent } from "./UserTextContent";
@@ -28,38 +37,39 @@ export const UserConversationContent: FC<{
if (content.source.type === "base64") {
return (
<Card
className="border-purple-200 bg-purple-50/50 dark:border-purple-800 dark:bg-purple-950/20"
className="border-purple-200 bg-purple-50/50 dark:border-purple-800 dark:bg-purple-950/20 mb-2 p-0 overflow-hidden"
id={id}
>
<CardHeader>
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-purple-600 dark:text-purple-400" />
<CardTitle className="text-sm font-medium">
<Trans id="user.content.image" message="Image" />
</CardTitle>
<Badge
variant="outline"
className="border-purple-300 text-purple-700 dark:border-purple-700 dark:text-purple-300"
>
{content.source.media_type}
</Badge>
</div>
<CardDescription className="text-xs">
<Trans
id="user.content.image.description"
message="User uploaded image content"
/>
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden bg-background">
<img
src={`data:${content.source.media_type};base64,${content.source.data}`}
alt="User uploaded content"
className="max-w-full h-auto max-h-96 object-contain"
/>
</div>
</CardContent>
<Collapsible>
<CollapsibleTrigger asChild>
<div className="cursor-pointer hover:bg-purple-100/50 dark:hover:bg-purple-900/20 transition-colors px-3 py-1.5 group">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium">
<Trans id="user.content.image" message="Image" />
</span>
<Badge
variant="outline"
className="border-purple-300 text-purple-700 dark:border-purple-700 dark:text-purple-300"
>
{content.source.media_type}
</Badge>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180 ml-auto" />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="py-3 px-4 border-t border-purple-200 dark:border-purple-800">
<div className="rounded-lg border overflow-hidden bg-background">
<img
src={`data:${content.source.media_type};base64,${content.source.data}`}
alt="User uploaded content"
className="max-w-full h-auto max-h-96 object-contain"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
@@ -93,6 +103,124 @@ export const UserConversationContent: FC<{
);
}
if (content.type === "document") {
if (content.source.type === "base64") {
// PDFの場合
if (content.source.media_type === "application/pdf") {
return (
<Card
className="border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20 mb-2 p-0 overflow-hidden"
id={id}
>
<Collapsible>
<CollapsibleTrigger asChild>
<div className="cursor-pointer hover:bg-blue-100/50 dark:hover:bg-blue-900/20 transition-colors px-3 py-1.5 group">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium">
<Trans
id="user.content.document.pdf"
message="PDF Document"
/>
</span>
<Badge
variant="outline"
className="border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-300"
>
{content.source.media_type}
</Badge>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180 ml-auto" />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="py-3 px-4 border-t border-blue-200 dark:border-blue-800">
<div className="rounded-lg border overflow-hidden bg-background">
<embed
src={`data:${content.source.media_type};base64,${content.source.data}`}
type="application/pdf"
className="w-full h-[600px]"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
}
if (content.source.type === "text") {
// テキストファイルの場合
return (
<Card
className="border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20 mb-2 p-0 overflow-hidden"
id={id}
>
<Collapsible>
<CollapsibleTrigger asChild>
<div className="cursor-pointer hover:bg-green-100/50 dark:hover:bg-green-900/20 transition-colors px-3 py-1.5 group">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium">
<Trans
id="user.content.document.text"
message="Text Document"
/>
</span>
<Badge
variant="outline"
className="border-green-300 text-green-700 dark:border-green-700 dark:text-green-300"
>
{content.source.media_type}
</Badge>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180 ml-auto" />
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="py-3 px-4 border-t border-green-200 dark:border-green-800">
<div className="rounded-lg border overflow-hidden bg-background">
<pre className="p-4 text-sm overflow-auto max-h-96">
{content.source.data}
</pre>
</div>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
return (
<Card
className="border-red-200 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20"
id={id}
>
<CardHeader>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
<CardTitle className="text-sm font-medium">
<Trans
id="user.content.unsupported_document"
message="Unsupported Document"
/>
</CardTitle>
<Badge variant="destructive">
<Trans id="common.error" message="Error" />
</Badge>
</div>
<CardDescription className="text-xs">
<Trans
id="user.content.unsupported_document.description"
message="Document type not supported for display"
/>
</CardDescription>
</CardHeader>
</Card>
);
}
if (content.type === "tool_result") {
// ツール結果は Assistant の呼び出し側に添えるので
return null;

View File

@@ -3,6 +3,7 @@ import type { FC } from "react";
import { useConfig } from "../../../../../../hooks/useConfig";
import {
ChatInput,
type MessageInput,
useContinueSessionProcessMutation,
} from "../../../../components/chatForm";
@@ -18,8 +19,8 @@ export const ContinueChat: FC<{
);
const { config } = useConfig();
const handleSubmit = async (message: string) => {
await continueSessionProcess.mutateAsync({ message, sessionProcessId });
const handleSubmit = async (input: MessageInput) => {
await continueSessionProcess.mutateAsync({ input, sessionProcessId });
};
const getPlaceholder = () => {

View File

@@ -3,6 +3,7 @@ import type { FC } from "react";
import { useConfig } from "../../../../../../hooks/useConfig";
import {
ChatInput,
type MessageInput,
useCreateSessionProcessMutation,
} from "../../../../components/chatForm";
@@ -14,9 +15,9 @@ export const ResumeChat: FC<{
const createSessionProcess = useCreateSessionProcessMutation(projectId);
const { config } = useConfig();
const handleSubmit = async (message: string) => {
const handleSubmit = async (input: MessageInput) => {
await createSessionProcess.mutateAsync({
message,
input,
baseSessionId: sessionId,
});
};

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const DocumentContentSchema = z.object({
type: z.literal("document"),
source: z.union([
z.object({
media_type: z.literal("text/plain"),
type: z.literal("text"),
data: z.string(),
}),
z.object({
media_type: z.enum(["application/pdf"]),
type: z.literal("base64"),
data: z.string(),
}),
]),
});

View File

@@ -5,6 +5,6 @@ export const ImageContentSchema = z.object({
source: z.object({
type: z.literal("base64"),
data: z.string(),
media_type: z.enum(["image/png"]),
media_type: z.enum(["image/png", "image/jpeg", "image/gif", "image/webp"]),
}),
});

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { DocumentContentSchema } from "../content/DocumentContentSchema";
import { ImageContentSchema } from "../content/ImageContentSchema";
import { TextContentSchema } from "../content/TextContentSchema";
import { ToolResultContentSchema } from "../content/ToolResultContentSchema";
@@ -8,6 +9,7 @@ const UserMessageContentSchema = z.union([
TextContentSchema,
ToolResultContentSchema,
ImageContentSchema,
DocumentContentSchema,
]);
export type UserMessageContent = z.infer<typeof UserMessageContentSchema>;

View File

@@ -772,7 +772,7 @@
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 183]]
},
"user.content.image": {
"message": "Image",
"message": "Attached Image",
"placeholders": {},
"comments": [],
"origin": [
@@ -781,7 +781,7 @@
39
]
],
"translation": "Image"
"translation": "Attached Image"
},
"assistant.tool.input_parameters": {
"message": "Input Parameters",
@@ -1569,7 +1569,7 @@
"translation": "Unsupported Media"
},
"user.content.image.description": {
"message": "User uploaded image content",
"message": "Image attached by user",
"placeholders": {},
"comments": [],
"origin": [
@@ -1578,7 +1578,55 @@
49
]
],
"translation": "User uploaded image content"
"translation": "Image attached by user"
},
"user.content.document.pdf": {
"message": "PDF Document",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/UserConversationContent.tsx",
121
]
],
"translation": "PDF Document"
},
"user.content.document.text": {
"message": "Text Document",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/UserConversationContent.tsx",
163
]
],
"translation": "Text Document"
},
"user.content.unsupported_document": {
"message": "Unsupported Document",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/UserConversationContent.tsx",
200
]
],
"translation": "Unsupported Document"
},
"user.content.unsupported_document.description": {
"message": "Document type not supported for display",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/UserConversationContent.tsx",
210
]
],
"translation": "Document type not supported for display"
},
"system_info.feature.agent_sdk.description": {
"message": "Uses Claude Agent SDK instead of Claude Code SDK (v1.0.125+)",

File diff suppressed because one or more lines are too long

View File

@@ -772,7 +772,7 @@
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 183]]
},
"user.content.image": {
"message": "Image",
"message": "Attached Image",
"placeholders": {},
"comments": [],
"origin": [
@@ -781,7 +781,7 @@
39
]
],
"translation": "画像"
"translation": "添付画像"
},
"assistant.tool.input_parameters": {
"message": "Input Parameters",
@@ -1569,7 +1569,7 @@
"translation": "サポートされていないメディア"
},
"user.content.image.description": {
"message": "User uploaded image content",
"message": "Image attached by user",
"placeholders": {},
"comments": [],
"origin": [
@@ -1578,7 +1578,55 @@
49
]
],
"translation": "ユーザーがアップロードした画像コンテンツ"
"translation": "ユーザーが添付した画像"
},
"user.content.document.pdf": {
"message": "PDF Document",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/UserConversationContent.tsx",
121
]
],
"translation": "PDF文書"
},
"user.content.document.text": {
"message": "Text Document",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/UserConversationContent.tsx",
163
]
],
"translation": "テキスト文書"
},
"user.content.unsupported_document": {
"message": "Unsupported Document",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/UserConversationContent.tsx",
200
]
],
"translation": "サポートされていない文書"
},
"user.content.unsupported_document.description": {
"message": "Document type not supported for display",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/UserConversationContent.tsx",
210
]
],
"translation": "文書タイプは表示がサポートされていません"
},
"system_info.feature.agent_sdk.description": {
"message": "Uses Claude Agent SDK instead of Claude Code SDK (v1.0.125+)",

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,18 @@ import type {
SDKMessage,
SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type {
DocumentBlockParam,
ImageBlockParam,
} from "@anthropic-ai/sdk/resources";
import { controllablePromise } from "../../../../lib/controllablePromise";
export type UserMessageInput = {
text: string;
images?: readonly ImageBlockParam[];
documents?: readonly DocumentBlockParam[];
};
export type OnMessage = (message: SDKMessage) => void | Promise<void>;
export type MessageGenerator = () => AsyncGenerator<
@@ -14,37 +24,61 @@ export type MessageGenerator = () => AsyncGenerator<
export const createMessageGenerator = (): {
generateMessages: MessageGenerator;
setNextMessage: (message: string) => void;
setNextMessage: (input: UserMessageInput) => void;
setHooks: (hooks: {
onNextMessageSet?: (message: string) => void | Promise<void>;
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
onNextMessageSet?: (input: UserMessageInput) => void | Promise<void>;
onNewUserMessageResolved?: (
input: UserMessageInput,
) => void | Promise<void>;
}) => void;
} => {
let sendMessagePromise = controllablePromise<string>();
let sendMessagePromise = controllablePromise<UserMessageInput>();
let registeredHooks: {
onNextMessageSet: ((message: string) => void | Promise<void>)[];
onNewUserMessageResolved: ((message: string) => void | Promise<void>)[];
onNextMessageSet: ((input: UserMessageInput) => void | Promise<void>)[];
onNewUserMessageResolved: ((
input: UserMessageInput,
) => void | Promise<void>)[];
} = {
onNextMessageSet: [],
onNewUserMessageResolved: [],
};
const createMessage = (message: string): SDKUserMessage => {
const createMessage = (input: UserMessageInput): SDKUserMessage => {
const { images = [], documents = [] } = input;
if (images.length === 0 && documents.length === 0) {
return {
type: "user",
message: {
role: "user",
content: input.text,
},
parent_tool_use_id: null,
} satisfies Omit<SDKUserMessage, "session_id"> as SDKUserMessage;
}
return {
type: "user",
message: {
role: "user",
content: message,
content: [
{
type: "text",
text: input.text,
},
...images,
...documents,
],
},
} as SDKUserMessage;
};
async function* generateMessages(): ReturnType<MessageGenerator> {
sendMessagePromise = controllablePromise<string>();
sendMessagePromise = controllablePromise<UserMessageInput>();
while (true) {
const message = await sendMessagePromise.promise;
sendMessagePromise = controllablePromise<string>();
sendMessagePromise = controllablePromise<UserMessageInput>();
void Promise.allSettled(
registeredHooks.onNewUserMessageResolved.map((hook) => hook(message)),
);
@@ -53,16 +87,18 @@ export const createMessageGenerator = (): {
}
}
const setNextMessage = (message: string) => {
sendMessagePromise.resolve(message);
const setNextMessage = (input: UserMessageInput) => {
sendMessagePromise.resolve(input);
void Promise.allSettled(
registeredHooks.onNextMessageSet.map((hook) => hook(message)),
registeredHooks.onNextMessageSet.map((hook) => hook(input)),
);
};
const setHooks = (hooks: {
onNextMessageSet?: (message: string) => void | Promise<void>;
onNewUserMessageResolved?: (message: string) => void | Promise<void>;
onNextMessageSet?: (input: UserMessageInput) => void | Promise<void>;
onNewUserMessageResolved?: (
input: UserMessageInput,
) => void | Promise<void>;
}) => {
registeredHooks = {
onNextMessageSet: [

View File

@@ -1,5 +1,6 @@
import { Effect } from "effect";
import type { UserEntry } from "../../../../lib/conversation-schema/entry/UserEntrySchema";
import type { UserMessageInput } from "../functions/createMessageGenerator";
import type { InitMessageContext } from "../types";
import * as ClaudeCode from "./ClaudeCode";
import type * as CCTask from "./ClaudeCodeTask";
@@ -10,7 +11,7 @@ export type CCSessionProcessDef = {
projectId: string;
cwd: string;
abortController: AbortController;
setNextMessage: (message: string) => void;
setNextMessage: (input: UserMessageInput) => void;
};
type CCSessionProcessStateBase = {

View File

@@ -4,6 +4,7 @@ import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { UserConfigService } from "../../platform/services/UserConfigService";
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import type { UserMessageInput } from "../functions/createMessageGenerator";
import { ClaudeCodeLifeCycleService } from "../services/ClaudeCodeLifeCycleService";
const LayerImpl = Effect.gen(function* () {
@@ -33,11 +34,11 @@ const LayerImpl = Effect.gen(function* () {
const createSessionProcess = (options: {
projectId: string;
message: string;
input: UserMessageInput;
baseSessionId?: string | undefined;
}) =>
Effect.gen(function* () {
const { projectId, message, baseSessionId } = options;
const { projectId, input, baseSessionId } = options;
const { project } = yield* projectRepository.getProject(projectId);
const userConfig = yield* userConfigService.getUserConfig();
@@ -56,7 +57,7 @@ const LayerImpl = Effect.gen(function* () {
sessionId: baseSessionId,
},
userConfig,
message,
input,
});
const { sessionId } = yield* result.yieldSessionInitialized();
@@ -75,13 +76,12 @@ const LayerImpl = Effect.gen(function* () {
const continueSessionProcess = (options: {
projectId: string;
continueMessage: string;
input: UserMessageInput;
baseSessionId: string;
sessionProcessId: string;
}) =>
Effect.gen(function* () {
const { projectId, continueMessage, baseSessionId, sessionProcessId } =
options;
const { projectId, input, baseSessionId, sessionProcessId } = options;
const { project } = yield* projectRepository.getProject(projectId);
@@ -94,7 +94,7 @@ const LayerImpl = Effect.gen(function* () {
const result = yield* claudeCodeLifeCycleService.continueTask({
sessionProcessId,
message: continueMessage,
input,
baseSessionId,
});

View File

@@ -0,0 +1,36 @@
import { z } from "zod";
/**
* Schema for image block parameter
*/
const imageBlockSchema = z.object({
type: z.literal("image"),
source: z.object({
type: z.literal("base64"),
media_type: z.enum(["image/png", "image/jpeg", "image/gif", "image/webp"]),
data: z.string(),
}),
});
/**
* Schema for document block parameter
*/
const documentBlockSchema = z.object({
type: z.literal("document"),
source: z.object({
type: z.literal("base64"),
media_type: z.enum(["application/pdf"]),
data: z.string(),
}),
});
/**
* Schema for user message input with optional images and documents
*/
export const userMessageInputSchema = z.object({
text: z.string().min(1),
images: z.array(imageBlockSchema).optional(),
documents: z.array(documentBlockSchema).optional(),
});
export type UserMessageInputSchema = z.infer<typeof userMessageInputSchema>;

View File

@@ -14,7 +14,10 @@ import type { EnvService } from "../../platform/services/EnvService";
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
import { VirtualConversationDatabase } from "../../session/infrastructure/VirtualConversationDatabase";
import type { SessionMetaService } from "../../session/services/SessionMetaService";
import { createMessageGenerator } from "../functions/createMessageGenerator";
import {
createMessageGenerator,
type UserMessageInput,
} from "../functions/createMessageGenerator";
import * as CCSessionProcess from "../models/CCSessionProcess";
import * as ClaudeCode from "../models/ClaudeCode";
import { ClaudeCodePermissionService } from "./ClaudeCodePermissionService";
@@ -46,9 +49,9 @@ const LayerImpl = Effect.gen(function* () {
const continueTask = (options: {
sessionProcessId: string;
baseSessionId: string;
message: string;
input: UserMessageInput;
}) => {
const { sessionProcessId, baseSessionId, message } = options;
const { sessionProcessId, baseSessionId, input } = options;
return Effect.gen(function* () {
const { sessionProcess, task } =
@@ -65,7 +68,7 @@ const LayerImpl = Effect.gen(function* () {
const virtualConversation =
yield* CCSessionProcess.createVirtualConversation(sessionProcess, {
sessionId: baseSessionId,
userMessage: message,
userMessage: input.text,
});
yield* virtualConversationDatabase.createVirtualConversation(
@@ -74,7 +77,7 @@ const LayerImpl = Effect.gen(function* () {
[virtualConversation],
);
sessionProcess.def.setNextMessage(message);
sessionProcess.def.setNextMessage(input);
return {
sessionProcess,
task,
@@ -89,9 +92,9 @@ const LayerImpl = Effect.gen(function* () {
projectId: string;
sessionId?: string;
};
message: string;
input: UserMessageInput;
}) => {
const { baseSession, message, userConfig } = options;
const { baseSession, input, userConfig } = options;
return Effect.gen(function* () {
const {
@@ -131,11 +134,11 @@ const LayerImpl = Effect.gen(function* () {
}>();
setMessageGeneratorHooks({
onNewUserMessageResolved: async (message) => {
onNewUserMessageResolved: async (input) => {
Effect.runFork(
sessionProcessService.toNotInitializedState({
sessionProcessId: sessionProcess.def.sessionProcessId,
rawUserMessage: message,
rawUserMessage: input.text,
}),
);
},
@@ -276,7 +279,7 @@ const LayerImpl = Effect.gen(function* () {
}),
);
setNextMessage(message);
setNextMessage(input);
try {
for await (const message of messageIter) {

View File

@@ -138,7 +138,9 @@ const LayerImpl = Effect.gen(function* () {
sessionId: undefined,
},
userConfig,
message: "/init",
input: {
text: "/init",
},
});
const { sessionId } = yield* result.yieldSessionFileCreated();

View File

@@ -27,7 +27,9 @@ export const executeJob = (job: SchedulerJob) =>
sessionId: message.baseSessionId ?? undefined,
},
userConfig,
message: message.content,
input: {
text: message.content,
},
});
});

View File

@@ -9,6 +9,7 @@ import packageJson from "../../../package.json" with { type: "json" };
import { ClaudeCodeController } from "../core/claude-code/presentation/ClaudeCodeController";
import { ClaudeCodePermissionController } from "../core/claude-code/presentation/ClaudeCodePermissionController";
import { ClaudeCodeSessionProcessController } from "../core/claude-code/presentation/ClaudeCodeSessionProcessController";
import { userMessageInputSchema } from "../core/claude-code/schema";
import { ClaudeCodeLifeCycleService } from "../core/claude-code/services/ClaudeCodeLifeCycleService";
import { TypeSafeSSE } from "../core/events/functions/typeSafeSSE";
import { SSEController } from "../core/events/presentation/SSEController";
@@ -356,7 +357,7 @@ export const routes = (app: HonoAppType) =>
"json",
z.object({
projectId: z.string(),
message: z.string(),
input: userMessageInputSchema,
baseSessionId: z.string().optional(),
}),
),
@@ -378,7 +379,7 @@ export const routes = (app: HonoAppType) =>
"json",
z.object({
projectId: z.string(),
continueMessage: z.string(),
input: userMessageInputSchema,
baseSessionId: z.string(),
}),
),