feat: add Todos

This commit is contained in:
Henry Li
2026-01-22 00:26:11 +08:00
parent 085dff8d29
commit 44850d9a61
16 changed files with 232 additions and 72 deletions

View File

@@ -28,7 +28,7 @@ export default function ChatLayout({
color={ color={
resolvedTheme === "dark" ? "#60A5FA" : "oklch(0 0.0098 87.47)" resolvedTheme === "dark" ? "#60A5FA" : "oklch(0 0.0098 87.47)"
} }
maxOpacity={resolvedTheme === "dark" ? 0.04 : 0.03} maxOpacity={resolvedTheme === "dark" ? 0.04 : 0.025}
flickerChance={0.1} flickerChance={0.1}
/> />
<FlickeringGrid <FlickeringGrid

View File

@@ -21,6 +21,7 @@ import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { Welcome } from "@/components/workspace/welcome"; import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -75,6 +76,8 @@ export default function ChatPage() {
setArtifacts(thread.values.artifacts); setArtifacts(thread.values.artifacts);
}, [setArtifacts, thread.values.artifacts]); }, [setArtifacts, thread.values.artifacts]);
const [todoListCollapsed, setTodoListCollapsed] = useState(true);
const handleSubmit = useSubmitThread({ const handleSubmit = useSubmitThread({
isNewThread, isNewThread,
threadId, threadId,
@@ -137,6 +140,7 @@ export default function ChatPage() {
className="size-full" className="size-full"
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
paddingBottom={todoListCollapsed ? 160 : 280}
/> />
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
@@ -149,19 +153,34 @@ export default function ChatPage() {
: "max-w-(--container-width-md)", : "max-w-(--container-width-md)",
)} )}
> >
<div {isNewThread && (
className={cn( <div
"absolute right-0 bottom-[136px] left-0 flex", className={cn(
isNewThread ? "" : "pointer-events-none opacity-0", "absolute right-0 bottom-[136px] left-0 flex",
)} )}
> >
<Welcome /> <Welcome />
</div> </div>
)}
<InputBox <InputBox
className={cn("bg-background/5 w-full")} className={cn("bg-background/5 w-full -translate-y-4")}
autoFocus={isNewThread} autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"} status={thread.isLoading ? "streaming" : "ready"}
context={settings.context} context={settings.context}
extraHeader={
thread.values.todos?.length ? (
<div className="mx-4">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
collapsed={todoListCollapsed}
onToggle={() =>
setTodoListCollapsed(!todoListCollapsed)
}
/>
</div>
) : null
}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }

View File

@@ -789,7 +789,7 @@ export const PromptInput = ({
ref={formRef} ref={formRef}
{...props} {...props}
> >
<InputGroup className="overflow-hidden">{children}</InputGroup> <InputGroup>{children}</InputGroup>
</form> </form>
</> </>
); );

View File

@@ -7,13 +7,13 @@ export interface ArtifactsContextType {
setArtifacts: (artifacts: string[]) => void; setArtifacts: (artifacts: string[]) => void;
selectedArtifact: string | null; selectedArtifact: string | null;
autoSelect: boolean;
select: (artifact: string, autoSelect?: boolean) => void;
deselect: () => void;
open: boolean; open: boolean;
autoOpen: boolean; autoOpen: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
deselect: () => void;
select: (artifact: string) => void;
} }
const ArtifactsContext = createContext<ArtifactsContextType | undefined>( const ArtifactsContext = createContext<ArtifactsContextType | undefined>(
@@ -27,17 +27,22 @@ interface ArtifactsProviderProps {
export function ArtifactsProvider({ children }: ArtifactsProviderProps) { export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
const [artifacts, setArtifacts] = useState<string[]>([]); const [artifacts, setArtifacts] = useState<string[]>([]);
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null); const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const { setOpen: setSidebarOpen } = useSidebar(); const { setOpen: setSidebarOpen } = useSidebar();
const select = (artifact: string) => { const select = (artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact); setSelectedArtifact(artifact);
setSidebarOpen(false); setSidebarOpen(false);
if (!autoSelect) {
setAutoSelect(false);
}
}; };
const deselect = () => { const deselect = () => {
setSelectedArtifact(null); setSelectedArtifact(null);
setAutoSelect(true);
}; };
const value: ArtifactsContextType = { const value: ArtifactsContextType = {
@@ -46,9 +51,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
open, open,
autoOpen, autoOpen,
autoSelect,
setOpen: (isOpen: boolean) => { setOpen: (isOpen: boolean) => {
if (!isOpen && autoOpen) { if (!isOpen && autoOpen) {
setAutoOpen(false); setAutoOpen(false);
setAutoSelect(false);
} }
setOpen(isOpen); setOpen(isOpen);
}, },

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { CheckIcon, LightbulbIcon, LightbulbOffIcon } from "lucide-react"; import { CheckIcon, LightbulbIcon, ListTodoIcon } from "lucide-react";
import { useCallback, useMemo, useState, type ComponentProps } from "react"; import { useCallback, useMemo, useState, type ComponentProps } from "react";
import { import {
@@ -35,6 +35,7 @@ export function InputBox({
autoFocus, autoFocus,
status = "ready", status = "ready",
context, context,
extraHeader,
onContextChange, onContextChange,
onSubmit, onSubmit,
onStop, onStop,
@@ -43,6 +44,7 @@ export function InputBox({
assistantId?: string | null; assistantId?: string | null;
status?: ChatStatus; status?: ChatStatus;
context: Omit<AgentThreadContext, "thread_id">; context: Omit<AgentThreadContext, "thread_id">;
extraHeader?: React.ReactNode;
onContextChange?: (context: Omit<AgentThreadContext, "thread_id">) => void; onContextChange?: (context: Omit<AgentThreadContext, "thread_id">) => void;
onSubmit?: (message: PromptInputMessage) => void; onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void; onStop?: () => void;
@@ -72,6 +74,12 @@ export function InputBox({
thinking_enabled: !context.thinking_enabled, thinking_enabled: !context.thinking_enabled,
}); });
}, [onContextChange, context]); }, [onContextChange, context]);
const handlePlanModeToggle = useCallback(() => {
onContextChange?.({
...context,
is_plan_mode: !context.is_plan_mode,
});
}, [onContextChange, context]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
@@ -89,7 +97,6 @@ export function InputBox({
<PromptInput <PromptInput
className={cn( className={cn(
"bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl", "bg-background/85 rounded-2xl backdrop-blur-sm transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-2xl",
"-translate-y-4 overflow-hidden",
className, className,
)} )}
globalDrop globalDrop
@@ -97,6 +104,11 @@ export function InputBox({
onSubmit={handleSubmit} onSubmit={handleSubmit}
{...props} {...props}
> >
{extraHeader && (
<div className="absolute top-0 right-0 left-0 z-100">
<div className="absolute right-0 bottom-0 left-0">{extraHeader}</div>
</div>
)}
<PromptInputBody> <PromptInputBody>
<PromptInputTextarea <PromptInputTextarea
className={cn("size-full")} className={cn("size-full")}
@@ -105,7 +117,7 @@ export function InputBox({
/> />
</PromptInputBody> </PromptInputBody>
<PromptInputFooter className="flex"> <PromptInputFooter className="flex">
<div> <div className="flex items-center">
<Tooltip <Tooltip
content={ content={
context.thinking_enabled ? ( context.thinking_enabled ? (
@@ -131,7 +143,7 @@ export function InputBox({
{context.thinking_enabled ? ( {context.thinking_enabled ? (
<LightbulbIcon className="text-primary size-4" /> <LightbulbIcon className="text-primary size-4" />
) : ( ) : (
<LightbulbOffIcon className="size-4" /> <LightbulbIcon className="size-4" />
)} )}
<span <span
className={cn( className={cn(
@@ -147,6 +159,47 @@ export function InputBox({
</PromptInputButton> </PromptInputButton>
)} )}
</Tooltip> </Tooltip>
<Tooltip
content={
context.is_plan_mode ? (
<div className="tex-sm flex flex-col gap-1">
<div>{t.inputBox.planMode}</div>
<div className="opacity-50">
{t.inputBox.clickToDisablePlanMode}
</div>
</div>
) : (
<div className="tex-sm flex flex-col gap-1">
<div>{t.inputBox.planMode}</div>
<div className="opacity-50">
{t.inputBox.clickToEnablePlanMode}
</div>
</div>
)
}
>
{selectedModel?.supports_thinking && (
<PromptInputButton onClick={handlePlanModeToggle}>
<>
{context.is_plan_mode ? (
<ListTodoIcon className="text-primary size-4" />
) : (
<ListTodoIcon className="size-4" />
)}
<span
className={cn(
"text-xs font-normal",
context.is_plan_mode
? "text-primary"
: "text-muted-foreground",
)}
>
{t.inputBox.planMode}
</span>
</>
</PromptInputButton>
)}
</Tooltip>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ModelSelector <ModelSelector

View File

@@ -2,10 +2,10 @@ import type { Message } from "@langchain/langgraph-sdk";
import { import {
BookOpenTextIcon, BookOpenTextIcon,
ChevronUp, ChevronUp,
FileTextIcon,
FolderOpenIcon, FolderOpenIcon,
GlobeIcon, GlobeIcon,
LightbulbIcon, LightbulbIcon,
ListTodoIcon,
MessageCircleQuestionMarkIcon, MessageCircleQuestionMarkIcon,
NotebookPenIcon, NotebookPenIcon,
SearchIcon, SearchIcon,
@@ -115,12 +115,17 @@ export function MessageGroup({
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
) : ( ) : (
<ToolCall key={step.id} {...step} /> <ToolCall key={step.id} {...step} isLoading={isLoading} />
), ),
)} )}
{lastToolCallStep && ( {lastToolCallStep && (
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}> <FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
<ToolCall key={lastToolCallStep.id} {...lastToolCallStep} /> <ToolCall
key={lastToolCallStep.id}
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
/>
</FlipDisplay> </FlipDisplay>
)} )}
</ChainOfThoughtContent> </ChainOfThoughtContent>
@@ -173,15 +178,20 @@ function ToolCall({
name, name,
args, args,
result, result,
isLast = false,
isLoading = false,
}: { }: {
id?: string; id?: string;
messageId?: string; messageId?: string;
name: string; name: string;
args: Record<string, unknown>; args: Record<string, unknown>;
result?: string | Record<string, unknown>; result?: string | Record<string, unknown>;
isLast?: boolean;
isLoading?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { setOpen, autoOpen, selectedArtifact, select } = useArtifacts(); const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
if (name === "web_search") { if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (typeof args.query === "string") { if (typeof args.query === "string") {
@@ -265,7 +275,7 @@ function ToolCall({
description = t.toolCalls.writeFile; description = t.toolCalls.writeFile;
} }
const path: string | undefined = (args as { path: string })?.path; const path: string | undefined = (args as { path: string })?.path;
if (autoOpen && path) { if (isLoading && isLast && autoOpen && autoSelect && path) {
setTimeout(() => { setTimeout(() => {
const url = new URL( const url = new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
@@ -273,7 +283,7 @@ function ToolCall({
if (selectedArtifact === url) { if (selectedArtifact === url) {
return; return;
} }
select(url); select(url, true);
setOpen(true); setOpen(true);
}, 100); }, 100);
} }
@@ -320,27 +330,7 @@ function ToolCall({
)} )}
</ChainOfThoughtStep> </ChainOfThoughtStep>
); );
} else if (name === "present_files") { } else if (name === "ask_clarification") {
return (
<ChainOfThoughtStep
key={id}
label={t.toolCalls.presentFiles}
icon={FileTextIcon}
>
<ChainOfThoughtSearchResult>
{Array.isArray((args as { filepaths: string[] }).filepaths) &&
(args as { filepaths: string[] }).filepaths.map(
(filepath: string) => (
<ChainOfThoughtSearchResult key={filepath}>
{filepath}
</ChainOfThoughtSearchResult>
),
)}
</ChainOfThoughtSearchResult>
</ChainOfThoughtStep>
);
}
if (name === "ask_clarification") {
return ( return (
<ChainOfThoughtStep <ChainOfThoughtStep
key={id} key={id}
@@ -348,6 +338,14 @@ function ToolCall({
icon={MessageCircleQuestionMarkIcon} icon={MessageCircleQuestionMarkIcon}
></ChainOfThoughtStep> ></ChainOfThoughtStep>
); );
} else if (name === "write_todos") {
return (
<ChainOfThoughtStep
key={id}
label={t.toolCalls.writeTodos}
icon={ListTodoIcon}
></ChainOfThoughtStep>
);
} else { } else {
const description: string | undefined = (args as { description: string }) const description: string | undefined = (args as { description: string })
?.description; ?.description;

View File

@@ -23,10 +23,12 @@ export function MessageList({
className, className,
threadId, threadId,
thread, thread,
paddingBottom = 160,
}: { }: {
className?: string; className?: string;
threadId: string; threadId: string;
thread: UseStream<AgentThreadState>; thread: UseStream<AgentThreadState>;
paddingBottom?: number;
}) { }) {
if (thread.isThreadLoading) { if (thread.isThreadLoading) {
return <MessageListSkeleton />; return <MessageListSkeleton />;
@@ -70,7 +72,7 @@ export function MessageList({
); );
})} })}
{thread.isLoading && <StreamingIndicator className="my-4" />} {thread.isLoading && <StreamingIndicator className="my-4" />}
<div className="h-40" /> <div style={{ height: `${paddingBottom}px` }} />
</ConversationContent> </ConversationContent>
</Conversation> </Conversation>
); );

View File

@@ -0,0 +1,68 @@
import { ChevronUpIcon } from "lucide-react";
import type { Todo } from "@/core/todos";
import { cn } from "@/lib/utils";
import {
QueueItem,
QueueItemContent,
QueueItemIndicator,
QueueList,
} from "../ai-elements/queue";
export function TodoList({
className,
todos,
collapsed = false,
onToggle,
}: {
className?: string;
todos: Todo[];
collapsed?: boolean;
onToggle?: () => void;
}) {
return (
<div
className={cn(
"flex h-fit w-full flex-col overflow-hidden rounded-t-xl border bg-white backdrop-blur-sm",
className,
)}
>
<header
className="bg-accent flex min-h-8 shrink-0 cursor-pointer items-center justify-between px-4 text-sm"
onClick={() => {
onToggle?.();
}}
>
<div className="text-muted-foreground">To-dos</div>
<div>
<ChevronUpIcon
className={cn(
"text-muted-foreground size-4 transition-transform duration-300 ease-out",
collapsed ? "" : "rotate-180",
)}
/>
</div>
</header>
<main
className={cn(
"bg-accent flex grow px-2 transition-all duration-300 ease-out",
collapsed ? "h-0" : "h-28",
)}
>
<QueueList className="bg-background mt-0 w-full rounded-t-xl">
{todos.map((todo, i) => (
<QueueItem key={i + (todo.content ?? "")}>
<div className="flex items-center gap-2">
<QueueItemIndicator completed={todo.status === "completed"} />
<QueueItemContent completed={todo.status === "completed"}>
{todo.content}
</QueueItemContent>
</div>
</QueueItem>
))}
</QueueList>
</main>
</div>
);
}

View File

@@ -41,6 +41,11 @@ export const enUS: Translations = {
thinkingDisabled: "Thinking is disabled", thinkingDisabled: "Thinking is disabled",
clickToDisableThinking: "Click to disable thinking", clickToDisableThinking: "Click to disable thinking",
clickToEnableThinking: "Click to enable thinking", clickToEnableThinking: "Click to enable thinking",
planMode: "Plan mode",
planModeEnabled: "Plan mode is enabled",
planModeDisabled: "Plan mode is disabled",
clickToDisablePlanMode: "Click to disable plan mode",
clickToEnablePlanMode: "Click to enable plan mode",
searchModels: "Search models...", searchModels: "Search models...",
}, },
@@ -87,6 +92,7 @@ export const enUS: Translations = {
listFolder: "List folder", listFolder: "List folder",
readFile: "Read file", readFile: "Read file",
writeFile: "Write file", writeFile: "Write file",
writeTodos: "Update to-do list",
}, },
// Settings // Settings

View File

@@ -38,6 +38,11 @@ export interface Translations {
thinkingDisabled: string; thinkingDisabled: string;
clickToDisableThinking: string; clickToDisableThinking: string;
clickToEnableThinking: string; clickToEnableThinking: string;
planMode: string;
planModeEnabled: string;
planModeDisabled: string;
clickToDisablePlanMode: string;
clickToEnablePlanMode: string;
searchModels: string; searchModels: string;
}; };
@@ -84,6 +89,7 @@ export interface Translations {
listFolder: string; listFolder: string;
readFile: string; readFile: string;
writeFile: string; writeFile: string;
writeTodos: string;
}; };
// Settings // Settings

View File

@@ -41,6 +41,11 @@ export const zhCN: Translations = {
thinkingDisabled: "思考功能已禁用", thinkingDisabled: "思考功能已禁用",
clickToDisableThinking: "点击禁用思考功能", clickToDisableThinking: "点击禁用思考功能",
clickToEnableThinking: "点击启用思考功能", clickToEnableThinking: "点击启用思考功能",
planMode: "To-do 模式",
planModeEnabled: "To-do 模式已启用",
planModeDisabled: "To-do 模式已禁用",
clickToDisablePlanMode: "点击禁用 To-do 模式",
clickToEnablePlanMode: "点击启用 To-do 模式",
searchModels: "搜索模型...", searchModels: "搜索模型...",
}, },
@@ -87,6 +92,7 @@ export const zhCN: Translations = {
listFolder: "列出文件夹", listFolder: "列出文件夹",
readFile: "读取文件", readFile: "读取文件",
writeFile: "写入文件", writeFile: "写入文件",
writeTodos: "更新 To-do 列表",
}, },
// Settings // Settings

View File

@@ -85,29 +85,6 @@ export function groupMessages<T>(
} }
} }
// if (!isLoading) {
// const lastGroup: MessageGroup | undefined = groups[groups.length - 1];
// if (
// lastGroup?.type === "assistant:processing" &&
// lastGroup.messages.length > 0
// ) {
// const lastMessage = lastGroup.messages[lastGroup.messages.length - 1]!;
// const reasoningContent = extractReasoningContentFromMessage(lastMessage);
// const content = extractContentFromMessage(lastMessage);
// if (reasoningContent && !content) {
// lastGroup.messages.pop();
// if (lastGroup.messages.length === 0) {
// groups.pop();
// }
// groups.push({
// id: lastMessage.id,
// type: "assistant",
// messages: [lastMessage],
// });
// }
// }
// }
const resultsOfGroups: T[] = []; const resultsOfGroups: T[] = [];
for (const group of groups) { for (const group of groups) {
const resultOfGroup = mapper(group); const resultOfGroup = mapper(group);

View File

@@ -4,6 +4,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
context: { context: {
model_name: "deepseek-v3.2", model_name: "deepseek-v3.2",
thinking_enabled: true, thinking_enabled: true,
is_plan_mode: true,
}, },
layout: { layout: {
sidebar_collapsed: false, sidebar_collapsed: false,
@@ -27,10 +28,18 @@ export function getLocalSettings(): LocalSettings {
try { try {
if (json) { if (json) {
const settings = JSON.parse(json); const settings = JSON.parse(json);
return { const mergedSettings = {
...DEFAULT_LOCAL_SETTINGS, ...DEFAULT_LOCAL_SETTINGS,
...settings, context: {
...DEFAULT_LOCAL_SETTINGS.context,
...settings.context,
},
layout: {
...DEFAULT_LOCAL_SETTINGS.layout,
...settings.layout,
},
}; };
return mergedSettings;
} }
} catch {} } catch {}
return DEFAULT_LOCAL_SETTINGS; return DEFAULT_LOCAL_SETTINGS;

View File

@@ -1,10 +1,13 @@
import { type BaseMessage } from "@langchain/core/messages"; import { type BaseMessage } from "@langchain/core/messages";
import type { Thread } from "@langchain/langgraph-sdk"; import type { Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> { export interface AgentThreadState extends Record<string, unknown> {
title: string; title: string;
messages: BaseMessage[]; messages: BaseMessage[];
artifacts: string[]; artifacts: string[];
todos?: Todo[];
} }
export interface AgentThread extends Thread<AgentThreadState> {} export interface AgentThread extends Thread<AgentThreadState> {}
@@ -13,4 +16,5 @@ export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string; thread_id: string;
model_name: string; model_name: string;
thinking_enabled: boolean; thinking_enabled: boolean;
is_plan_mode: boolean;
} }

View File

@@ -0,0 +1 @@
export * from "./types";

View File

@@ -0,0 +1,4 @@
export interface Todo {
content?: string;
status?: "pending" | "in_progress" | "completed";
}