diff --git a/.env.example b/.env.example index 94f7e0b..28afdec 100644 --- a/.env.example +++ b/.env.example @@ -25,12 +25,12 @@ DAEMON_COMMAND_LEASE_MS=60000 FLUE_DB_TOKEN=replace-with-a-long-random-token # Agent model provider -AGENT_MODEL_PROVIDER=cheaptricks -AGENT_MODEL_NAME=glm-5.2 +AGENT_MODEL_PROVIDER=xiaomi +AGENT_MODEL_NAME=mimo-v2.5 AGENT_MODEL_API=openai-completions AGENT_MODEL_BASE_URL=https://ai.example.com/v1 AGENT_MODEL_API_KEY=replace-with-provider-api-key -AGENT_MODEL_CONTEXT_WINDOW=262000 +AGENT_MODEL_CONTEXT_WINDOW=1048576 AGENT_MODEL_MAX_TOKENS=131072 # Self-hosted git (Gitea) — canonical repository host and API token diff --git a/apps/web/package.json b/apps/web/package.json index bc779f4..18306c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@code/env": "workspace:*", "@code/primitives": "workspace:*", "@code/ui": "workspace:*", + "@code/work-os": "workspace:*", "@flue/react": "1.0.0-beta.9", "@flue/sdk": "1.0.0-beta.9", "@react-router/fs-routes": "^8.1.0", diff --git a/apps/web/src/components/chat/chat-attachments.tsx b/apps/web/src/components/chat/chat-attachments.tsx new file mode 100644 index 0000000..df8b2a0 --- /dev/null +++ b/apps/web/src/components/chat/chat-attachments.tsx @@ -0,0 +1,162 @@ +import { useConvexAccessToken } from "@code/auth/web"; +import { + Attachment, + AttachmentAction, + AttachmentActions, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, +} from "@code/ui/components/attachment"; +import type { FlueConversationPart } from "@flue/react"; +import { ImageIcon, X } from "lucide-react"; +import { useEffect, useState } from "react"; + +import type { PendingChatImage } from "@/lib/chat/attachments"; + +type FilePart = Extract; + +const isDirectlyRenderableUrl = (url: string): boolean => + url.startsWith("blob:") || url.startsWith("data:"); + +const useAuthenticatedImageSource = (url?: string): string | undefined => { + const resolveAccessToken = useConvexAccessToken(); + const [loaded, setLoaded] = useState<{ + readonly source: string; + readonly url: string; + }>(); + + useEffect(() => { + if (!url || isDirectlyRenderableUrl(url)) { + return; + } + let active = true; + let objectUrl: string | undefined; + const load = async (): Promise => { + try { + const accessToken = await resolveAccessToken(); + const response = await fetch(url, { + headers: accessToken + ? { authorization: `Bearer ${accessToken}` } + : undefined, + }); + if (!response.ok) { + return; + } + objectUrl = URL.createObjectURL(await response.blob()); + if (active) { + setLoaded({ source: objectUrl, url }); + } + } catch { + // The attachment remains represented by its filename and media type. + } + }; + void load(); + return () => { + active = false; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [resolveAccessToken, url]); + + if (url && isDirectlyRenderableUrl(url)) { + return url; + } + return loaded && loaded.url === url ? loaded.source : undefined; +}; + +const ImagePreview = ({ + alt, + mediaType, + url, +}: { + readonly alt: string; + readonly mediaType: string; + readonly url?: string; +}) => { + const source = useAuthenticatedImageSource(url); + return ( + + {source && mediaType.startsWith("image/") ? ( + {alt} + ) : ( + + )} + + ); +}; + +export const PendingChatAttachments = ({ + images, + onRemove, +}: { + readonly images: readonly PendingChatImage[]; + readonly onRemove: (id: string) => void; +}) => ( + + {images.map((image) => ( + + + + {image.file.name} + + + onRemove(image.id)} + > + + + + + ))} + +); + +export const MessageAttachments = ({ + parts, +}: { + readonly parts: readonly FilePart[]; +}) => { + if (parts.length === 0) { + return null; + } + return ( + + {parts.map((part, index) => { + const title = part.filename ?? `Image ${index + 1}`; + return ( + + + + {title} + {part.mediaType} + + + ); + })} + + ); +}; diff --git a/apps/web/src/components/chat/chat-message.tsx b/apps/web/src/components/chat/chat-message.tsx index 36e0ecf..e487e69 100644 --- a/apps/web/src/components/chat/chat-message.tsx +++ b/apps/web/src/components/chat/chat-message.tsx @@ -7,26 +7,63 @@ import { MobileChatBubble, MobileChatMessage, } from "@code/ui/components/mobile-chat"; -import type { ReactNode } from "react"; +import type { FlueConversationMessage } from "@flue/react"; -import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms"; -import type { ChatMessageProps } from "@/lib/chat/types"; +import { + getMessageText, + getReasoningText, + hasToolActivity, + isReasoningStreaming, + isMessageStreaming, +} from "@/lib/chat/transforms"; +import type { + AssistantResponseState, + ChatMessageProps, +} from "@/lib/chat/types"; import { AssistantIdentity } from "./assistant-identity"; +import { MessageAttachments } from "./chat-attachments"; import { ChatToolCall } from "./chat-tool-call"; -export const ChatMessage = ({ message }: ChatMessageProps) => { - const isUser = message.role === "user"; - const isStreaming = isMessageStreaming(message); - const text = getMessageText(message); - - if (message.parts.length === 0 && !isStreaming) { +const ReasoningTrace = ({ + isStreaming, + text, +}: { + readonly isStreaming: boolean; + readonly text: string; +}) => { + if (!text) { return null; } + return ( +
+ + {isStreaming ? "Thinking live" : "Thinking trace"} + + + {text} + +
+ ); +}; - let content: ReactNode = text; - if (!isUser) { - content = text ? ( +const AssistantText = ({ + hasReasoning, + isStreaming, + text, +}: { + readonly hasReasoning: boolean; + readonly isStreaming: boolean; + readonly text: string; +}) => { + if (text) { + return ( { > {text} - ) : ( -
- - - -
); } + if (hasReasoning) { + return null; + } + return ( +
+ + + +
+ ); +}; + +const assistantState = ( + isStreaming: boolean, + reasoningStreaming: boolean, + text: string +): AssistantResponseState | undefined => { + if (reasoningStreaming && !text) { + return "thinking"; + } + return isStreaming ? "writing" : undefined; +}; + +const ToolActivity = ({ + hidden, + message, +}: { + readonly hidden: boolean; + readonly message: FlueConversationMessage; +}) => { + if (hidden) { + return null; + } + return message.parts.map((part) => + part.type === "dynamic-tool" ? ( + + ) : null + ); +}; + +export const ChatMessage = ({ + hideToolActivity = false, + message, +}: ChatMessageProps) => { + const isUser = message.role === "user"; + const isStreaming = isMessageStreaming(message); + const text = getMessageText(message); + const reasoning = getReasoningText(message); + const reasoningStreaming = isReasoningStreaming(message); + const fileParts = message.parts.filter((part) => part.type === "file"); + + if ( + hideToolActivity && + !isUser && + hasToolActivity(message) && + !text && + !reasoning + ) { + return null; + } + + if (message.parts.length === 0 && !isStreaming) { + return null; + } const sender = isUser ? "user" : "assistant"; + const content = isUser ? ( + text + ) : ( + + ); + const identityState = assistantState(isStreaming, reasoningStreaming, text); return (
- {!isUser && ( - - )} + {isUser ? null : } - {message.parts.map((part) => - part.type === "dynamic-tool" ? ( - - ) : null + + {isUser ? null : ( + )} +
diff --git a/apps/web/src/components/slice-one/slice-one-page.tsx b/apps/web/src/components/slice-one/slice-one-page.tsx new file mode 100644 index 0000000..928390e --- /dev/null +++ b/apps/web/src/components/slice-one/slice-one-page.tsx @@ -0,0 +1,416 @@ +import { + Conversation, + ConversationContent, +} from "@code/ui/components/ai-elements/conversation"; +import { Button } from "@code/ui/components/button"; +import { projectWorkNotices } from "@code/work-os"; +import { + ChevronRight, + FolderGit2, + LoaderCircle, + Menu, + MessageSquareText, + ImagePlus, + Send, + Sparkles, + X, +} from "lucide-react"; +import { useMemo, useRef, useState } from "react"; + +import { PendingChatAttachments } from "@/components/chat/chat-attachments"; +import { ChatMessage } from "@/components/chat/chat-message"; +import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response"; +import { useChatImages } from "@/hooks/chat/use-chat-images"; +import { useSliceOne } from "@/hooks/slice-one/use-slice-one"; +import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport"; +import { chatImageToPromptImage } from "@/lib/chat/attachments"; +import { + buildSliceOneTimeline, + findSourceMessageTarget, +} from "@/lib/slice-one/presentation"; + +type SliceWork = NonNullable["works"]>[number]; +const EMPTY_WORKS: readonly SliceWork[] = []; + +interface WorkCardProps { + readonly onSourceSelect: (rawText: string) => void; + readonly work: SliceWork; +} + +const WorkCard = ({ onSourceSelect, work }: WorkCardProps) => { + const [sourcesOpen, setSourcesOpen] = useState(false); + const sources = work.signals.flatMap((signal) => signal.sources); + return ( +
+
+ + + +
+

+ Proposed Work +

+

+ {work.title} +

+

+ {work.objective} +

+
+
+ + {sourcesOpen ? ( +
+ {sources.map((source) => ( + + ))} +
+ ) : null} +
+ ); +}; + +export const SliceOnePage = () => { + const slice = useSliceOne(); + const viewportStyle = useVisualViewportStyle(); + const [draft, setDraft] = useState(""); + const attachments = useChatImages(); + const imageInput = useRef(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [highlightedMessageId, setHighlightedMessageId] = useState(); + const highlightTimer = useRef | null>(null); + const works = slice.works ?? EMPTY_WORKS; + const workById = useMemo( + () => new Map(works.map((work) => [String(work._id), work])), + [works] + ); + const notices = useMemo(() => projectWorkNotices(works), [works]); + const timeline = useMemo( + () => buildSliceOneTimeline(slice.agent.messages, notices), + [notices, slice.agent.messages] + ); + const busy = + slice.agent.status === "submitted" || slice.agent.status === "streaming"; + + const revealSourceMessage = (rawText: string) => { + const messageId = findSourceMessageTarget(slice.agent.messages, rawText); + if (!messageId) { + return; + } + setDrawerOpen(false); + setHighlightedMessageId(messageId); + if (highlightTimer.current) { + clearTimeout(highlightTimer.current); + } + requestAnimationFrame(() => { + document + .querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + highlightTimer.current = setTimeout( + () => setHighlightedMessageId(undefined), + 1800 + ); + }; + + if (slice.projects === undefined) { + return ( +
+ +
+ ); + } + + if (!slice.selectedProject) { + return ( +
+
{ + event.preventDefault(); + void slice.connectRepository(); + }} + > + + + +

Connect one project

+

+ Slice 1 turns actionable conversation into proposed Work with exact + provenance. +

+ slice.setRepository(event.target.value)} + placeholder="https://github.com/owner/repository" + required + value={slice.repository} + /> + {slice.error ? ( +

{slice.error.message}

+ ) : null} + +
+
+ ); + } + + const send = async () => { + const message = draft.trim(); + if (!message || busy) { + return; + } + const images = await Promise.all( + attachments.images.map(chatImageToPromptImage) + ); + await slice.agent.sendMessage(message, { images }); + setDraft(""); + attachments.clear(); + }; + + return ( +
+
+
+
+ +

+ Conversation to proposed Work +

+
+ +
+ + + {slice.agent.historyReady && timeline.length === 0 ? ( +
+
+ +

+ What should move forward? +

+

+ Describe an outcome or problem. Casual conversation stays + conversation. +

+
+
+ ) : null} + {timeline.map((item) => { + if (item.kind === "work") { + const work = workById.get(item.notice.workId); + return work ? ( +
+

+ Work proposed from this conversation +

+ +
+ ) : null; + } + return ( +
+ +
+ ); + })} + {slice.agent.status === "submitted" ? ( + + ) : null} +
+
+
+ {attachments.images.length > 0 ? ( +
+ +
+ ) : null} + {slice.agent.error ? ( +

+ {slice.agent.error.message} +

+ ) : null} + {attachments.error ? ( +

+ {attachments.error} +

+ ) : null} +
+ { + attachments.addFiles(event.target.files); + event.target.value = ""; + }} + ref={imageInput} + type="file" + /> + +