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
147 changes: 78 additions & 69 deletions frontend/src/components/console/task/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,77 @@ import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"
import { Label } from "@/components/ui/label"
import { IconCircle, IconCircleCheck, IconLoader, IconPlayerStopFilled, IconSubtask } from "@tabler/icons-react"
import type { AvailableCommands, PlanEntry, RepoFileChange, TaskPlan, TaskStreamStatus, TaskWebSocketManager } from "./ws-manager"

export interface PlanStepsBlockProps {
plan: TaskPlan
streamStatus: TaskStreamStatus
}

export function PlanStepsBlock({ plan, streamStatus }: PlanStepsBlockProps) {
const [planOpened, setPlanOpened] = React.useState(false)

React.useEffect(() => {
if (!plan) return
setPlanOpened(plan.entries.some((entry: PlanEntry) => entry.status !== "completed"))
}, [plan])

if (!plan || plan.entries.length === 0) return null

const renderPlan = () => {
if (planOpened) {
return plan.entries.map((entry: PlanEntry, index: number) => (
<div key={index} className="flex items-center gap-2">
{entry.status === "in_progress" && streamStatus === "executing" ? (
<IconLoader className="min-w-3 size-3 animate-spin" />
) : entry.status === "completed" ? (
<IconCircleCheck className="min-w-3 size-3 text-primary" />
) : (
<IconCircle className="min-w-3 size-3 text-muted-foreground" />
)}
<div
className={cn(
"line-clamp-1 text-xs",
entry.status === "completed" ? "text-muted-foreground" : "",
entry.status === "in_progress" && streamStatus === "executing" ? "text-primary" : ""
)}
>
{entry.content}
</div>
</div>
))
} else {
const firstInProgress = plan.entries.find((entry: PlanEntry) => entry.status === "in_progress")
if (!firstInProgress || streamStatus !== "executing") return null
return (
<div className="flex items-center gap-2">
{firstInProgress.status === "in_progress" ? (
<IconLoader className="min-w-3 size-3 animate-spin" />
) : firstInProgress.status === "completed" ? (
<IconCircleCheck className="min-w-3 size-3 text-primary" />
) : (
<IconCircle className="min-w-3 size-3 text-muted-foreground" />
)}
<div className="line-clamp-1 text-xs text-primary">{firstInProgress.content}</div>
</div>
)
}
}

return (
<div className="flex w-full flex-col gap-2 border rounded-md p-2 shrink-0">
<div className="flex items-center justify-between">
<Label>
<IconSubtask className="size-4 text-primary" />
执行步骤 ({plan.entries.filter((entry: PlanEntry) => entry.status === "completed").length}/{plan.entries.length})
</Label>
<Button variant={planOpened ? "secondary" : "ghost"} size="icon-sm" className="size-5" onClick={() => setPlanOpened(!planOpened)}>
{planOpened ? <ChevronsDownUp className="size-4" /> : <ChevronsUpDown className="size-4" />}
</Button>
</div>
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto">{renderPlan()}</div>
</div>
)
}
import { TaskChatInputBox } from "./chat-inputbox"
import { cn } from "@/lib/utils"
import { FileChangesDialog } from "./file-changes-dialog"
Expand All @@ -20,6 +91,8 @@ interface TaskChatPanelProps {
streamStatus: TaskStreamStatus
disabled: boolean
plan: TaskPlan | null
/** 当为 'sticky' 时,执行步骤由父组件渲染并固定在顶部,本组件不渲染 */
planPosition?: "inline" | "sticky"
availableCommands: AvailableCommands | null
sending: boolean
queueSize: number
Expand All @@ -32,8 +105,7 @@ interface TaskChatPanelProps {
taskManager: TaskWebSocketManager | null
}

export const TaskChatPanel = ({ scrollContainerRef: externalScrollRef, inputPortalTargetRef, messages, cli, streamStatus, disabled, plan, availableCommands, sending, sendUserInput, sendCancelCommand, sendResetSession, sendReloadSession, queueSize, fileChanges, fileChangesMap, taskManager }: TaskChatPanelProps) => {
const [planOpened, setPlanOpened] = React.useState(false)
export const TaskChatPanel = ({ scrollContainerRef: externalScrollRef, inputPortalTargetRef, messages, cli, streamStatus, disabled, plan, planPosition = "inline", availableCommands, sending, sendUserInput, sendCancelCommand, sendResetSession, sendReloadSession, queueSize, fileChanges, fileChangesMap, taskManager }: TaskChatPanelProps) => {
const [timeCost, setTimeCost] = React.useState(0)
const internalScrollRef = React.useRef<HTMLDivElement>(null)
const scrollContainerRef = externalScrollRef ?? internalScrollRef
Expand All @@ -42,18 +114,9 @@ export const TaskChatPanel = ({ scrollContainerRef: externalScrollRef, inputPort


React.useEffect(() => {
setShowSubmitButton(streamStatus === 'waiting')
setShowSubmitButton(streamStatus === "waiting")
}, [streamStatus])

React.useEffect(() => {
if (!plan) {
return
}

setPlanOpened(plan.entries.some((entry: PlanEntry) => entry.status !== 'completed'))

}, [plan])

React.useEffect(() => {
if (streamStatus === 'executing') {
setTimeCost(0)
Expand Down Expand Up @@ -139,65 +202,11 @@ export const TaskChatPanel = ({ scrollContainerRef: externalScrollRef, inputPort
}
}

const renderPlan = () => {
if (!plan || plan.entries.length === 0) {
return null
}
if (planOpened) {
return plan.entries.map((entry: PlanEntry, index: number) => (
<div key={index} className="flex items-center gap-2">
{entry.status === 'in_progress' && streamStatus === 'executing' ? (
<IconLoader className="min-w-3 size-3 animate-spin" />
) : (
entry.status === 'completed' ? (
<IconCircleCheck className="min-w-3 size-3 text-primary" />
) : (
<IconCircle className="min-w-3 size-3 text-muted-foreground" />
)
)}
<div className={cn("line-clamp-1 text-xs", entry.status === 'completed' ? 'text-muted-foreground' : '', (entry.status === 'in_progress' && streamStatus === 'executing') ? 'text-primary' : '')}>
{entry.content}
</div>
</div>
))
} else {
const firstInProgress = plan.entries.find((entry: PlanEntry) => entry.status === 'in_progress')
if (!firstInProgress || streamStatus !== 'executing') {
return null
}
return <div className="flex items-center gap-2">
{firstInProgress.status === 'in_progress' ? (
<IconLoader className="min-w-3 size-3 animate-spin" />
) : (
firstInProgress.status === 'completed' ? (
<IconCircleCheck className="min-w-3 size-3 text-primary" />
) : (
<IconCircle className="min-w-3 size-3 text-muted-foreground" />
)
)}
<div className="line-clamp-1 text-xs text-primary">
{firstInProgress.content}
</div>
</div>
}
}

return (
<div className={cn("flex flex-col gap-2 w-full", externalScrollRef ? "min-h-full" : "h-full")}>
{plan && plan.entries.length > 0 && <div className="flex w-full flex-col gap-2 border rounded-md p-2">
<div className="flex items-center justify-between">
<Label>
<IconSubtask className="size-4 text-primary" />
执行步骤 ({plan.entries.filter((entry: PlanEntry) => entry.status === 'completed').length}/{plan.entries.length})
</Label>
<Button variant={planOpened ? "secondary" : "ghost"} size="icon-sm" className="size-5" onClick={() => setPlanOpened(!planOpened)}>
{planOpened ? <ChevronsDownUp className="size-4" /> : <ChevronsUpDown className="size-4" />}
</Button>
</div>
<div className="flex flex-col gap-2 max-h-48 overflow-y-auto">
{renderPlan()}
</div>
</div>}
{planPosition === "inline" && plan && plan.entries.length > 0 && (
<PlanStepsBlock plan={plan} streamStatus={streamStatus} />
)}

<div
ref={!externalScrollRef ? internalScrollRef : undefined}
Expand Down
38 changes: 4 additions & 34 deletions frontend/src/components/console/task/task-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { getBrandFromModelName, getGitPlatformIcon, getHostBadges, getImageShortName, getInterfaceTypeBadge, getModelHealthBadge, getOSFromImageName, getOwnerTypeBadge, getRepoIcon, getRepoNameFromUrl, getSkillTagIcon, selectHost, selectImage, selectModel } from "@/utils/common";
import { apiRequest } from "@/utils/requestUtils";
import { IconBug, IconLink, IconPuzzle, IconSend, IconSourceCode, IconSquareRoundedLetterOFilled, IconTerminal2, IconUpload, IconUser, IconVocabulary, IconXboxX } from "@tabler/icons-react";
import { IconBug, IconLink, IconPuzzle, IconSend, IconSourceCode, IconTerminal2, IconUpload, IconUser, IconVocabulary, IconXboxX } from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSettingsDialog } from "@/pages/console/user/page";
Expand Down Expand Up @@ -102,8 +102,7 @@ export function TaskInput({ repos, onTaskCreated }: TaskInputProps) {
const [selectedSkill, setSelectedSkill] = useState<string[]>(defaultSkills);
const [skillList, setSkillList] = useState<DomainSkill[]>([]);

// 运行参数状态
const [selectedTool, setSelectedTool] = useState<string>("");
// 运行参数状态(工具固定为 opencode)
const [selectedModelId, setSelectedModelId] = useState<string>("");
const [selectedHostId, setSelectedHostId] = useState<string>("");
const [selectedImageId, setSelectedImageId] = useState<string>("");
Expand Down Expand Up @@ -222,7 +221,6 @@ export function TaskInput({ repos, onTaskCreated }: TaskInputProps) {

const setDefaultConfig = () => {
setSelectedModelId(selectModel(modelsWithEconomy, true))
setSelectedTool(ConstsCliName.CliNameOpencode)
setSelectedHostId(selectHost(hosts, true))

if (user.role === ConstsUserRole.UserRoleSubAccount) {
Expand All @@ -247,11 +245,7 @@ export function TaskInput({ repos, onTaskCreated }: TaskInputProps) {
return true
}

if (selectedTool === ConstsCliName.CliNameCodex) {
return model.interface_type === ConstsInterfaceType.InterfaceTypeOpenAIResponse
} else if (selectedTool === ConstsCliName.CliNameClaude) {
return model.interface_type === ConstsInterfaceType.InterfaceTypeAnthropic
}
// 固定使用 opencode,支持所有模型
return true;
};

Expand All @@ -274,7 +268,7 @@ export function TaskInput({ repos, onTaskCreated }: TaskInputProps) {
const executeTask = async () => {
setCreatingTask(true);
await apiRequest('v1UsersTasksCreate', {
cli_name: selectedTool,
cli_name: ConstsCliName.CliNameOpencode,
content: taskContent,
git_identity_id: selectedIdentityId || undefined,
host_id: selectedHostId,
Expand Down Expand Up @@ -752,30 +746,6 @@ export function TaskInput({ repos, onTaskCreated }: TaskInputProps) {
<DialogHeader>
<DialogTitle>任务参数</DialogTitle>
</DialogHeader>
<Field>
<FieldLabel>开发工具</FieldLabel>
<FieldContent>
<Select value={selectedTool} onValueChange={setSelectedTool}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择开发工具" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ConstsCliName.CliNameClaude}>
<Icon name="claude" className="size-4" />
Claude Code
</SelectItem>
<SelectItem value={ConstsCliName.CliNameCodex} disabled>
<Icon name="openai" className="size-4" />
OpenAI Codex
</SelectItem>
<SelectItem value={ConstsCliName.CliNameOpencode}>
<IconSquareRoundedLetterOFilled className="size-4 text-primary" />
OpenCode
</SelectItem>
</SelectContent>
</Select>
</FieldContent>
</Field>
<Field>
<FieldLabel>大模型</FieldLabel>
<FieldContent>
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/console/user/task/task-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ConstsTaskStatus, type DomainProjectTask } from "@/api/Api"
import { useBreadcrumbTask } from "@/components/console/breadcrumb-task-context"
import { TaskChatPanel } from "@/components/console/task/chat-panel"
import { PlanStepsBlock, TaskChatPanel } from "@/components/console/task/chat-panel"
import { TaskFileExplorer } from "@/components/console/task/task-file-explorer"
import { TaskTerminalPanel } from "@/components/console/task/task-terminal-panel"
import type { MessageType } from "@/components/console/task/message"
Expand Down Expand Up @@ -228,6 +228,11 @@ export default function TaskDetailPage() {

const chatSection = (
<div className={cn("flex flex-col h-full min-h-0 gap-2", hasPanel ? "max-w-full" : "")}>
{plan && plan.entries.length > 0 && (
<div className={cn("shrink-0", hasPanel ? "w-full" : "mx-auto max-w-[800px] w-full")}>
<PlanStepsBlock plan={plan} streamStatus={streamStatus} />
</div>
)}
<div ref={chatScrollRef} className="flex-1 min-h-0 overflow-y-auto min-w-0">
<div className={cn("min-h-full", hasPanel ? "w-full" : "mx-auto max-w-[800px]")}>
<TaskChatPanel
Expand All @@ -240,6 +245,7 @@ export default function TaskDetailPage() {
disabled={!vmOnline}
sending={sending}
plan={plan}
planPosition="sticky"
sendUserInput={sendUserInput}
sendCancelCommand={sendCancelCommand}
sendResetSession={sendResetSession}
Expand Down
48 changes: 28 additions & 20 deletions frontend/src/pages/console/user/task/task-dev.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TypesVirtualMachineStatus, type DomainProjectTask } from "@/api/Api"
import { TaskChatPanel } from "@/components/console/task/chat-panel"
import { PlanStepsBlock, TaskChatPanel } from "@/components/console/task/chat-panel"
import { TaskFileTree } from "@/components/console/task/file-tree"
import type { MessageType } from "@/components/console/task/message"
import { TaskDetailPanel, type TaskDetailPanelRef } from "@/components/console/task/task-detail-panel"
Expand Down Expand Up @@ -431,27 +431,35 @@ export default function TaskDevelopPage() {
{(showTerminalPanel || showFilesPanel) && <ResizableHandle className="invisible hidden sm:block" />}
<ResizablePanel defaultSize={40} minSize={35} style={{overflow: 'visible'}}>
<div className="w-full h-full flex flex-col min-w-0">
{plan && plan.entries.length > 0 && (
<div className="shrink-0 flex justify-center">
<div className="max-w-[800px] w-full">
<PlanStepsBlock plan={plan} streamStatus={streamStatus} />
</div>
</div>
)}
<div ref={chatScrollRef} className="flex-1 min-h-0 overflow-y-auto flex justify-center">
<div className="max-w-[800px] w-full min-h-full">
<TaskChatPanel
scrollContainerRef={chatScrollRef}
inputPortalTargetRef={chatInputRef}
messages={messages}
cli={task?.cli_name}
availableCommands={availableCommands}
streamStatus={streamStatus}
disabled={task?.virtualmachine?.status !== TypesVirtualMachineStatus.VirtualMachineStatusOnline}
sending={sending}
plan={plan}
sendUserInput={sendUserInput}
sendCancelCommand={sendCancelCommand}
sendResetSession={sendResetSession}
sendReloadSession={sendReloadSession}
queueSize={queueSize}
fileChanges={changedPaths}
fileChangesMap={fileChangesMap}
taskManager={taskManager.current}
/>
<TaskChatPanel
scrollContainerRef={chatScrollRef}
inputPortalTargetRef={chatInputRef}
messages={messages}
cli={task?.cli_name}
availableCommands={availableCommands}
streamStatus={streamStatus}
disabled={task?.virtualmachine?.status !== TypesVirtualMachineStatus.VirtualMachineStatusOnline}
sending={sending}
plan={plan}
planPosition="sticky"
sendUserInput={sendUserInput}
sendCancelCommand={sendCancelCommand}
sendResetSession={sendResetSession}
sendReloadSession={sendReloadSession}
queueSize={queueSize}
fileChanges={changedPaths}
fileChangesMap={fileChangesMap}
taskManager={taskManager.current}
/>
</div>
</div>
<div className="shrink-0 w-full flex justify-center">
Expand Down
Loading