openCanvas
This commit is contained in:
1 parent
050b1ad78e
commit
1be600155b
18 files changed
+754
-101
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 更新时间:2026-05-15
|
||||
> 更新时间:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
@@ -18,10 +18,10 @@
|
||||
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
|
||||
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
|
||||
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
|
||||
| 前后端阶段八 | 快捷动作系统 | ⬜ 待开始 |
|
||||
| 前后端阶段九 | 本地模型支持完善 | ⬜ 待开始 |
|
||||
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
|
||||
|
||||
**下一个要做的任务**:前后端阶段八 — 快捷动作系统(CanvasEmptyState 模板触发 + 快捷动作按钮)
|
||||
**当前状态**:全部 9 个阶段已完成 🎉
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ interface BackendModel {
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
supports_tool_calling?: boolean;
|
||||
// Canvas-phase-9 fields (now returned by backend)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function loadCanvasModels(): Promise<CanvasModel[]> {
|
||||
|
||||
@@ -12,38 +12,168 @@ export type CanvasRunInput = {
|
||||
quick_action_id?: string;
|
||||
};
|
||||
|
||||
/** Partial artifact data extracted from streaming tool_call_chunks */
|
||||
export type StreamingArtifactPartial = {
|
||||
type?: "text" | "code";
|
||||
title?: string;
|
||||
content?: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type CanvasStreamCallbacks = {
|
||||
onMessage?: (msg: CanvasMessage) => void;
|
||||
onArtifact?: (artifact: CanvasArtifact) => void;
|
||||
/** Called repeatedly during streaming as update_artifact args accumulate */
|
||||
onStreamingArtifact?: (partial: StreamingArtifactPartial) => void;
|
||||
onDone?: () => void;
|
||||
onError?: (err: unknown) => void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming JSON arg extraction helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function unescapeJsonFragment(raw: string): string {
|
||||
// Remove trailing incomplete escape sequence
|
||||
const safe = raw.replace(/\\[^"nrts\\bfu]?$/, "");
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < safe.length) {
|
||||
if (safe[i] === "\\" && i + 1 < safe.length) {
|
||||
const next = safe[i + 1]!;
|
||||
if (next === "n") { out += "\n"; i += 2; }
|
||||
else if (next === "r") { out += "\r"; i += 2; }
|
||||
else if (next === "t") { out += "\t"; i += 2; }
|
||||
else if (next === '"') { out += '"'; i += 2; }
|
||||
else if (next === "\\") { out += "\\"; i += 2; }
|
||||
else if (next === "b") { out += "\b"; i += 2; }
|
||||
else if (next === "f") { out += "\f"; i += 2; }
|
||||
else { out += safe[i]!; i++; }
|
||||
} else if (safe[i] === '"') {
|
||||
// Unescaped closing quote — content value ends here
|
||||
break;
|
||||
} else {
|
||||
out += safe[i]!;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Extract a fully-quoted string field, e.g. "type": "text" */
|
||||
function extractStringField(args: string, field: string): string | undefined {
|
||||
const re = new RegExp(`"${field}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, "");
|
||||
const m = re.exec(args);
|
||||
if (!m) return undefined;
|
||||
return unescapeJsonFragment(m[1]!);
|
||||
}
|
||||
|
||||
/** Extract the content field even from incomplete JSON (no closing quote yet) */
|
||||
function extractContentField(args: string): string | undefined {
|
||||
const keyIdx = args.indexOf('"content"');
|
||||
if (keyIdx === -1) return undefined;
|
||||
const afterKey = args.slice(keyIdx + '"content"'.length);
|
||||
const colonQuote = /^\s*:\s*"/.exec(afterKey);
|
||||
if (!colonQuote) return undefined;
|
||||
const valueStart = colonQuote[0].length;
|
||||
const raw = afterKey.slice(valueStart);
|
||||
return unescapeJsonFragment(raw);
|
||||
}
|
||||
|
||||
function extractPartialArtifactArgs(args: string): StreamingArtifactPartial {
|
||||
return {
|
||||
type: extractStringField(args, "type") as "text" | "code" | undefined,
|
||||
title: extractStringField(args, "title"),
|
||||
language: extractStringField(args, "language"),
|
||||
content: extractContentField(args),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main stream function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function streamCanvasRun(
|
||||
threadId: string,
|
||||
input: CanvasRunInput,
|
||||
callbacks: CanvasStreamCallbacks,
|
||||
modelName?: string | null,
|
||||
): Promise<void> {
|
||||
const client = getAPIClient();
|
||||
try {
|
||||
const stream = client.runs.stream(threadId, CANVAS_AGENT_ID, {
|
||||
input,
|
||||
streamMode: ["values", "messages-tuple"],
|
||||
...(modelName ? { config: { configurable: { model_name: modelName } } } : {}),
|
||||
});
|
||||
|
||||
// Accumulate tool call args by index position
|
||||
const toolCallAccumulator: Record<number, { name: string; args: string }> = {};
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// Cast to string: SDK typings only list a subset of event names
|
||||
const event = chunk.event as string;
|
||||
const data = (chunk as { event: string; data: unknown }).data;
|
||||
|
||||
if (event === "values") {
|
||||
const state = data as Record<string, unknown>;
|
||||
if (state.artifact) {
|
||||
callbacks.onArtifact?.(state.artifact as CanvasArtifact);
|
||||
}
|
||||
} else if (event === "messages/tuple") {
|
||||
const [msg] = data as [CanvasMessage];
|
||||
if (msg) {
|
||||
callbacks.onMessage?.(msg);
|
||||
} else if (event === "messages") {
|
||||
const tuple = data as [unknown, unknown] | unknown;
|
||||
const rawMsg = Array.isArray(tuple) ? tuple[0] : tuple;
|
||||
if (!rawMsg || typeof rawMsg !== "object") continue;
|
||||
|
||||
const msg = rawMsg as Record<string, unknown>;
|
||||
|
||||
// Emit text content to chat panel
|
||||
callbacks.onMessage?.(msg as unknown as CanvasMessage);
|
||||
|
||||
// ---- Streaming tool call args (tool_call_chunks) ----
|
||||
const toolCallChunks = msg.tool_call_chunks;
|
||||
if (Array.isArray(toolCallChunks) && toolCallChunks.length > 0) {
|
||||
for (const tcChunk of toolCallChunks as Array<Record<string, unknown>>) {
|
||||
const idx = (tcChunk.index as number | undefined) ?? 0;
|
||||
if (!toolCallAccumulator[idx]) {
|
||||
toolCallAccumulator[idx] = {
|
||||
name: (tcChunk.name as string | undefined) ?? "",
|
||||
args: "",
|
||||
};
|
||||
} else if (tcChunk.name) {
|
||||
toolCallAccumulator[idx]!.name = tcChunk.name as string;
|
||||
}
|
||||
toolCallAccumulator[idx]!.args += (tcChunk.args as string | undefined) ?? "";
|
||||
|
||||
if (toolCallAccumulator[idx]!.name === "update_artifact") {
|
||||
const partial = extractPartialArtifactArgs(toolCallAccumulator[idx]!.args);
|
||||
if (partial.content !== undefined || partial.title !== undefined) {
|
||||
callbacks.onStreamingArtifact?.(partial);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Complete tool_calls (non-streaming or final snapshot) ----
|
||||
const toolCalls = msg.tool_calls;
|
||||
if (Array.isArray(toolCalls)) {
|
||||
for (const tc of toolCalls as Array<Record<string, unknown>>) {
|
||||
if (tc.name === "update_artifact" && tc.args) {
|
||||
try {
|
||||
const args =
|
||||
typeof tc.args === "string"
|
||||
? (JSON.parse(tc.args) as Record<string, unknown>)
|
||||
: (tc.args as Record<string, unknown>);
|
||||
callbacks.onStreamingArtifact?.({
|
||||
type: args.type as "text" | "code" | undefined,
|
||||
title: args.title as string | undefined,
|
||||
content: args.content as string | undefined,
|
||||
language: args.language as string | undefined,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
|
||||
import type { StreamingArtifactPartial } from "../../api/runs";
|
||||
import {
|
||||
getCurrentArtifactContent,
|
||||
isCodeArtifact,
|
||||
} from "../../utils/artifact";
|
||||
import { CanvasArtifactHeader } from "./CanvasArtifactHeader";
|
||||
import { CanvasEmptyState } from "./CanvasEmptyState";
|
||||
import { CanvasQuickActionBar } from "./CanvasQuickActionBar";
|
||||
import { TextArtifactRenderer } from "./TextArtifactRenderer";
|
||||
import { CodeArtifactRenderer } from "./CodeArtifactRenderer";
|
||||
import type {
|
||||
@@ -11,11 +17,76 @@ import type {
|
||||
CanvasArtifactText,
|
||||
} from "../../types/artifact";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingOverlay — shown while update_artifact args are streaming in
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StreamingTextPreview({ partial }: { partial: StreamingArtifactPartial }) {
|
||||
const hasContent = partial.content && partial.content.trim();
|
||||
|
||||
if (!hasContent) {
|
||||
// Args started but content not yet arrived — show skeleton
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
<div className="space-y-1 text-center">
|
||||
{partial.title ? (
|
||||
<p className="text-sm font-medium text-foreground">{partial.title}</p>
|
||||
) : null}
|
||||
<p className="text-xs">AI 正在生成文档…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stream content into the real renderer (read-only, isStreaming = true)
|
||||
if (partial.type === "code") {
|
||||
const fakeContent: CanvasArtifactCode = {
|
||||
index: 0,
|
||||
type: "code",
|
||||
title: partial.title ?? "代码",
|
||||
language: (partial.language as CanvasArtifactCode["language"]) ?? "other",
|
||||
code: partial.content ?? "",
|
||||
};
|
||||
return <CodeArtifactRenderer content={fakeContent} isStreaming />;
|
||||
}
|
||||
|
||||
const fakeContent: CanvasArtifactText = {
|
||||
index: 0,
|
||||
type: "text",
|
||||
title: partial.title ?? "文档",
|
||||
fullMarkdown: partial.content ?? "",
|
||||
};
|
||||
return <TextArtifactRenderer content={fakeContent} isStreaming />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CanvasArtifactPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CanvasArtifactPanel() {
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const { artifact, streamingArtifact, isStreaming } = useCanvasGraphContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialType = searchParams.get("type") as "text" | "code" | null;
|
||||
|
||||
// While streaming and we have partial data, show the streaming preview
|
||||
if (isStreaming && streamingArtifact) {
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b shrink-0 bg-muted/30">
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{streamingArtifact.title ?? "正在生成…"}
|
||||
</span>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary shrink-0" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StreamingTextPreview partial={streamingArtifact} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10">
|
||||
@@ -29,6 +100,7 @@ export function CanvasArtifactPanel() {
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<CanvasArtifactHeader />
|
||||
<CanvasQuickActionBar />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{content && isCodeArtifact(content) ? (
|
||||
<CodeArtifactRenderer content={content as CanvasArtifactCode} />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasQuickActions } from "../../hooks/useCanvasQuickActions";
|
||||
import type { QuickAction } from "../../types/quick-action";
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
|
||||
import { QUICK_ACTIONS_CODE, QUICK_ACTIONS_TEXT } from "../../constants";
|
||||
|
||||
export function CanvasQuickActionBar() {
|
||||
const { artifact, isStreaming } = useCanvasGraphContext();
|
||||
const { triggerQuickAction } = useCanvasQuickActions();
|
||||
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
if (!content) return null;
|
||||
|
||||
const actions: QuickAction[] = isCodeArtifact(content)
|
||||
? QUICK_ACTIONS_CODE
|
||||
: QUICK_ACTIONS_TEXT;
|
||||
|
||||
return (
|
||||
<div className="border-b px-4 py-1.5 flex items-center gap-1.5 flex-wrap shrink-0 bg-background">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
disabled={isStreaming}
|
||||
onClick={() => void triggerQuickAction(action.id)}
|
||||
className="inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors
|
||||
bg-background text-foreground hover:bg-accent hover:text-accent-foreground
|
||||
disabled:opacity-40 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,9 +67,10 @@ function getLanguageExtension(
|
||||
|
||||
interface CodeArtifactRendererProps {
|
||||
content: CanvasArtifactCode;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function CodeArtifactRenderer({ content }: CodeArtifactRendererProps) {
|
||||
export function CodeArtifactRenderer({ content, isStreaming: _isStreaming }: CodeArtifactRendererProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { handleCodeSelectionChange } = useCanvasSelection();
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
|
||||
interface TextArtifactRendererProps {
|
||||
content: CanvasArtifactText;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function TextArtifactRenderer({ content }: TextArtifactRendererProps) {
|
||||
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
|
||||
const { handleTextPointerUp } = useCanvasSelection();
|
||||
|
||||
if (!content.fullMarkdown) {
|
||||
@@ -136,6 +137,9 @@ export function TextArtifactRenderer({ content }: TextArtifactRendererProps) {
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
import { useCanvasStream } from "../../hooks/useCanvasStream";
|
||||
import type { CanvasMessage } from "../../types/thread";
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
|
||||
import {
|
||||
buildMessageWithSelection,
|
||||
@@ -15,6 +18,182 @@ import {
|
||||
resolveCodeSelection,
|
||||
} from "../../utils/selection";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type ClarificationPayload = {
|
||||
question?: string;
|
||||
context?: string | null;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripThinkBlocks(text: string): string {
|
||||
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
||||
}
|
||||
|
||||
function getClarificationPayload(msg: CanvasMessage): ClarificationPayload | null {
|
||||
const c = msg.additional_kwargs?.clarification;
|
||||
if (typeof c !== "object" || c === null) return null;
|
||||
return c as ClarificationPayload;
|
||||
}
|
||||
|
||||
function getTextContent(msg: CanvasMessage): string {
|
||||
return typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClarificationCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ClarificationCard({
|
||||
message,
|
||||
disabled,
|
||||
onSubmit,
|
||||
}: {
|
||||
message: CanvasMessage;
|
||||
disabled: boolean;
|
||||
onSubmit: (answer: string) => void;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message)!;
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const options = (payload.options ?? []).filter(
|
||||
(o) => typeof o.label === "string" && o.label.trim(),
|
||||
);
|
||||
|
||||
const busy = disabled || submitted;
|
||||
|
||||
function submitAnswer(answer: string) {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed || busy) return;
|
||||
setSubmitted(true);
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
|
||||
const fallback = stripThinkBlocks(getTextContent(message));
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">需要你的确认</div>
|
||||
{payload.context ? (
|
||||
<p className="text-muted-foreground text-sm leading-6">{payload.context}</p>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 font-medium">
|
||||
{payload.question || fallback}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id || option.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={busy}
|
||||
onClick={() => submitAnswer(`我选择:${option.label}`)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span className="text-sm">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="text-muted-foreground text-xs font-normal leading-5">
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allow_custom !== false ? (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={customAnswer}
|
||||
onChange={(e) => setCustomAnswer(e.currentTarget.value)}
|
||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||
disabled={busy}
|
||||
className="min-h-20 resize-none text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !customAnswer.trim()}
|
||||
onClick={() => submitAnswer(customAnswer)}
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
发送回答
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AiMessageContent — renders a single AI message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AiMessageContent({
|
||||
message,
|
||||
isActiveStream,
|
||||
isStreaming,
|
||||
onClarificationSubmit,
|
||||
}: {
|
||||
message: CanvasMessage;
|
||||
isActiveStream: boolean;
|
||||
isStreaming: boolean;
|
||||
onClarificationSubmit: (answer: string) => void;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isActiveStream);
|
||||
const clarification = getClarificationPayload(message);
|
||||
|
||||
if (clarification) {
|
||||
return (
|
||||
<ClarificationCard
|
||||
message={message}
|
||||
disabled={isStreaming}
|
||||
onSubmit={onClarificationSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayText = stripThinkBlocks(getTextContent(message));
|
||||
if (!displayText) return null;
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={displayText}
|
||||
isLoading={isActiveStream}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatWelcome
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChatWelcome() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-6 space-y-3">
|
||||
@@ -33,12 +212,17 @@ function ChatWelcome() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CanvasChatPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CanvasChatPanel() {
|
||||
const { messages, isStreaming, artifact } = useCanvasGraphContext();
|
||||
const { sendMessage } = useCanvasStream();
|
||||
const { selection, clearSelection } = useCanvasSelection();
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
const hasSelection = hasActiveSelection(selection);
|
||||
@@ -68,12 +252,16 @@ export function CanvasChatPanel() {
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Scroll to bottom whenever messages change or streaming state updates
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
async function handleSend() {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
setInput("");
|
||||
|
||||
// Embed selection context into the message when something is selected
|
||||
const finalText = hasSelection
|
||||
? buildMessageWithSelection(text, selection, resolvedCode)
|
||||
: text;
|
||||
@@ -89,6 +277,22 @@ export function CanvasChatPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the last AI message — used to decide which message gets streaming animation
|
||||
const lastAiIndex = (() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]!.type !== "human") return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
|
||||
// Whether the last AI message has any visible text (not just think blocks / empty)
|
||||
const lastAiHasVisibleContent = (() => {
|
||||
if (lastAiIndex < 0) return false;
|
||||
const msg = messages[lastAiIndex]!;
|
||||
if (getClarificationPayload(msg)) return true;
|
||||
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Message list */}
|
||||
@@ -97,10 +301,6 @@ export function CanvasChatPanel() {
|
||||
<div className="p-4 space-y-4">
|
||||
{messages.map((msg, i) => {
|
||||
const isHuman = msg.type === "human";
|
||||
const text =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,15 +312,22 @@ export function CanvasChatPanel() {
|
||||
<Sparkles className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${
|
||||
isHuman
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
|
||||
{isHuman ? (
|
||||
<div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
|
||||
{getTextContent(msg)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[85%] min-w-0">
|
||||
<AiMessageContent
|
||||
message={msg}
|
||||
isActiveStream={isStreaming && i === lastAiIndex}
|
||||
isStreaming={isStreaming}
|
||||
onClarificationSubmit={(answer) => void sendMessage(answer)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHuman && (
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
|
||||
<User className="w-3 h-3" />
|
||||
@@ -129,16 +336,20 @@ export function CanvasChatPanel() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isStreaming && (
|
||||
|
||||
{isStreaming && !lastAiHasVisibleContent && (
|
||||
<div className="flex gap-2 justify-start">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg px-3 py-2">
|
||||
<div className="bg-muted rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">正在生成…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
) : (
|
||||
<ChatWelcome />
|
||||
@@ -155,9 +366,7 @@ export function CanvasChatPanel() {
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{selection.textSelection ? "选中文本:" : "选中代码:"}
|
||||
</span>
|
||||
<span className="truncate min-w-0 font-mono">
|
||||
{selectionPreview}
|
||||
</span>
|
||||
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
import { getCurrentArtifactContent } from "../../utils/artifact";
|
||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||
|
||||
export function CanvasHeader() {
|
||||
const navigate = useNavigate();
|
||||
@@ -66,11 +67,7 @@ export function CanvasHeader() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{threadId && (
|
||||
<span className="text-[11px] text-muted-foreground font-mono hidden sm:block shrink-0 max-w-[120px] truncate">
|
||||
{threadId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<CanvasModelSelector />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,17 +7,24 @@ import { CanvasHeader } from "./CanvasHeader";
|
||||
import { CanvasResizableLayout } from "./CanvasResizableLayout";
|
||||
|
||||
export function CanvasShell() {
|
||||
const { threadId } = useCanvasThreadContext();
|
||||
const { threadId, setThreadId } = useCanvasThreadContext();
|
||||
const { setMessages, setArtifact } = useCanvasGraphContext();
|
||||
|
||||
// Load existing thread state when navigating to a saved thread
|
||||
// Load existing thread state when navigating to a saved thread.
|
||||
// If the thread doesn't exist in the backend (e.g. pre-generated UUID from
|
||||
// CanvasHomePage or stale URL after a DB reset), clear threadId so that
|
||||
// the next sendMessage call creates a fresh thread instead of hitting 404.
|
||||
useEffect(() => {
|
||||
if (!threadId) return;
|
||||
let cancelled = false;
|
||||
|
||||
getCanvasThread(threadId)
|
||||
.then((values) => {
|
||||
if (cancelled || !values) return;
|
||||
if (cancelled) return;
|
||||
if (!values) {
|
||||
setThreadId(null);
|
||||
return;
|
||||
}
|
||||
if (values.messages?.length) {
|
||||
setMessages(values.messages);
|
||||
}
|
||||
@@ -32,7 +39,7 @@ export function CanvasShell() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [threadId, setMessages, setArtifact]);
|
||||
}, [threadId, setMessages, setArtifact, setThreadId]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Cpu } from "lucide-react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { loadCanvasModels } from "../../api/models";
|
||||
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
|
||||
import type { CanvasModel } from "../../types/model";
|
||||
|
||||
export function CanvasModelSelector() {
|
||||
const { modelName, setModelName } = useCanvasThreadContext();
|
||||
const [models, setModels] = useState<CanvasModel[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCanvasModels()
|
||||
.then((list) => {
|
||||
setModels(list);
|
||||
if (!modelName && list.length > 0) {
|
||||
setModelName(list[0].name);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// fail silently — backend may be unavailable during dev
|
||||
});
|
||||
// only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const selected = models.find((m) => m.name === modelName) ?? models[0];
|
||||
|
||||
return (
|
||||
<Select value={selected.name} onValueChange={setModelName}>
|
||||
<SelectTrigger className="h-7 text-xs border-dashed w-auto min-w-[120px] max-w-[200px] gap-1.5">
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
{selected.is_local && (
|
||||
<Cpu className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{selected.display_name ?? selected.name}
|
||||
</span>
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate">{m.display_name ?? m.name}</span>
|
||||
{m.is_local && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1 shrink-0">
|
||||
本地
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -8,15 +8,18 @@ import {
|
||||
|
||||
import type { CanvasArtifact } from "../types/artifact";
|
||||
import type { CanvasMessage } from "../types/thread";
|
||||
import type { StreamingArtifactPartial } from "../api/runs";
|
||||
|
||||
export interface CanvasGraphContextValue {
|
||||
messages: CanvasMessage[];
|
||||
artifact: CanvasArtifact | null;
|
||||
streamingArtifact: StreamingArtifactPartial | null;
|
||||
isStreaming: boolean;
|
||||
setMessages: (messages: CanvasMessage[]) => void;
|
||||
/** Upsert a message by id — concatenates string content for streaming deltas. */
|
||||
appendMessage: (msg: CanvasMessage) => void;
|
||||
setArtifact: (artifact: CanvasArtifact | null) => void;
|
||||
setStreamingArtifact: (partial: StreamingArtifactPartial | null) => void;
|
||||
setIsStreaming: (v: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +28,8 @@ const CanvasGraphContext = createContext<CanvasGraphContextValue | null>(null);
|
||||
export function CanvasGraphProvider({ children }: { children: ReactNode }) {
|
||||
const [messages, setMessages] = useState<CanvasMessage[]>([]);
|
||||
const [artifact, setArtifact] = useState<CanvasArtifact | null>(null);
|
||||
const [streamingArtifact, setStreamingArtifact] =
|
||||
useState<StreamingArtifactPartial | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
const appendMessage = useCallback((msg: CanvasMessage) => {
|
||||
@@ -50,10 +55,12 @@ export function CanvasGraphProvider({ children }: { children: ReactNode }) {
|
||||
value={{
|
||||
messages,
|
||||
artifact,
|
||||
streamingArtifact,
|
||||
isStreaming,
|
||||
setMessages,
|
||||
appendMessage,
|
||||
setArtifact,
|
||||
setStreamingArtifact,
|
||||
setIsStreaming,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,2 +1,25 @@
|
||||
// Phase 9: trigger quick actions and write quick_action_id to run input
|
||||
export {};
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
|
||||
import type { QuickActionId } from "../types/quick-action";
|
||||
import { getCurrentArtifactContent } from "../utils/artifact";
|
||||
import { buildQuickActionMessage } from "../utils/quick-actions";
|
||||
import { useCanvasStream } from "./useCanvasStream";
|
||||
|
||||
export function useCanvasQuickActions() {
|
||||
const { sendMessage } = useCanvasStream();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
|
||||
const triggerQuickAction = useCallback(
|
||||
async (actionId: QuickActionId) => {
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
if (!content) return;
|
||||
|
||||
const message = buildQuickActionMessage(actionId);
|
||||
await sendMessage(message, { quick_action_id: actionId });
|
||||
},
|
||||
[sendMessage, artifact],
|
||||
);
|
||||
|
||||
return { triggerQuickAction };
|
||||
}
|
||||
@@ -11,8 +11,14 @@ import type { CanvasMessage } from "../types/thread";
|
||||
export function useCanvasStream() {
|
||||
const navigate = useNavigate();
|
||||
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
|
||||
const { messages, setMessages, appendMessage, setArtifact, setIsStreaming } =
|
||||
useCanvasGraphContext();
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
appendMessage,
|
||||
setArtifact,
|
||||
setStreamingArtifact,
|
||||
setIsStreaming,
|
||||
} = useCanvasGraphContext();
|
||||
|
||||
// Ref so callbacks inside streamCanvasRun always see the latest messages
|
||||
// without adding `messages` to useCallback's dep array (avoids re-creation
|
||||
@@ -51,24 +57,34 @@ export function useCanvasStream() {
|
||||
};
|
||||
|
||||
// 4. Stream
|
||||
await streamCanvasRun(activeThreadId, input, {
|
||||
onMessage(msg) {
|
||||
// Only surface AI text replies; skip tool messages
|
||||
if (msg.type === "ai") {
|
||||
appendMessage(msg);
|
||||
}
|
||||
await streamCanvasRun(
|
||||
activeThreadId,
|
||||
input,
|
||||
{
|
||||
onMessage(msg) {
|
||||
if (msg.type === "ai" || msg.type === "AIMessageChunk") {
|
||||
appendMessage(msg);
|
||||
}
|
||||
},
|
||||
onStreamingArtifact(partial) {
|
||||
setStreamingArtifact(partial);
|
||||
},
|
||||
onArtifact(artifact) {
|
||||
setArtifact(artifact);
|
||||
setStreamingArtifact(null);
|
||||
},
|
||||
onDone() {
|
||||
setIsStreaming(false);
|
||||
setStreamingArtifact(null);
|
||||
},
|
||||
onError(err) {
|
||||
console.error("[canvas] stream error:", err);
|
||||
setIsStreaming(false);
|
||||
setStreamingArtifact(null);
|
||||
},
|
||||
},
|
||||
onArtifact(artifact) {
|
||||
setArtifact(artifact);
|
||||
},
|
||||
onDone() {
|
||||
setIsStreaming(false);
|
||||
},
|
||||
onError(err) {
|
||||
console.error("[canvas] stream error:", err);
|
||||
setIsStreaming(false);
|
||||
},
|
||||
});
|
||||
modelName ?? undefined,
|
||||
);
|
||||
},
|
||||
// messagesRef replaces direct `messages` dep — intentional
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -80,6 +96,7 @@ export function useCanvasStream() {
|
||||
appendMessage,
|
||||
setMessages,
|
||||
setArtifact,
|
||||
setStreamingArtifact,
|
||||
setIsStreaming,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { FileText, Code2, Sparkles } from "lucide-react";
|
||||
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createCanvasThread } from "../api/threads";
|
||||
|
||||
const QUICK_START_ITEMS = [
|
||||
{
|
||||
@@ -21,10 +22,30 @@ const QUICK_START_ITEMS = [
|
||||
|
||||
export default function CanvasHomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function handleQuickStart(type: "text" | "code") {
|
||||
const threadId = uuidv4();
|
||||
navigate(`${threadId}?type=${type}`);
|
||||
async function handleQuickStart(type: "text" | "code") {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const threadId = await createCanvasThread();
|
||||
navigate(`${threadId}?type=${type}`);
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to create thread:", err);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlankCanvas() {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const threadId = await createCanvasThread();
|
||||
navigate(threadId);
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to create thread:", err);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -44,11 +65,16 @@ export default function CanvasHomePage() {
|
||||
{QUICK_START_ITEMS.map(({ type, icon: Icon, title, description }) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleQuickStart(type)}
|
||||
className="group flex flex-col gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:border-primary/50 hover:shadow-md hover:bg-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => void handleQuickStart(type)}
|
||||
disabled={loading}
|
||||
className="group flex flex-col gap-3 rounded-xl border bg-card p-5 text-left transition-all hover:border-primary/50 hover:shadow-md hover:bg-accent/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-muted group-hover:bg-primary/10 transition-colors">
|
||||
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm">{title}</p>
|
||||
@@ -65,8 +91,12 @@ export default function CanvasHomePage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground text-xs"
|
||||
onClick={() => navigate(uuidv4())}
|
||||
onClick={() => void handleBlankCanvas()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
) : null}
|
||||
或直接开始空白 Canvas
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,2 +1,16 @@
|
||||
// Phase 9: map quick action ids to run inputs
|
||||
export {};
|
||||
import type { QuickActionId } from "../types/quick-action";
|
||||
|
||||
const QUICK_ACTION_MESSAGES: Record<string, string> = {
|
||||
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
|
||||
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
|
||||
formalize: "请将当前内容改写为正式、专业的语气",
|
||||
casualize: "请将当前内容改写为口语化、友好的语气",
|
||||
add_comments: "请为当前代码添加清晰实用的注释",
|
||||
add_logs: "请为当前代码在关键位置添加适当的日志语句",
|
||||
fix_bugs: "请检查并修复当前代码中的所有 Bug",
|
||||
optimize_code: "请对当前代码进行性能和可读性优化",
|
||||
};
|
||||
|
||||
export function buildQuickActionMessage(id: QuickActionId): string {
|
||||
return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
|
||||
}
|
||||
@@ -3,9 +3,50 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_config
|
||||
from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.model_config import ModelConfig
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["models"])
|
||||
|
||||
_PROVIDER_KEYWORDS: list[tuple[str, str]] = [
|
||||
("langchain_openai", "openai"),
|
||||
("langchain_anthropic", "anthropic"),
|
||||
("langchain_google", "google"),
|
||||
("langchain_groq", "groq"),
|
||||
("langchain_mistralai", "mistral"),
|
||||
("langchain_cohere", "cohere"),
|
||||
("ollama", "ollama"),
|
||||
("vllm", "vllm"),
|
||||
]
|
||||
|
||||
_LOCAL_PROVIDERS = {"ollama", "vllm"}
|
||||
|
||||
|
||||
def _derive_provider(use: str) -> str | None:
|
||||
use_lower = use.lower()
|
||||
for keyword, provider in _PROVIDER_KEYWORDS:
|
||||
if keyword in use_lower:
|
||||
return provider
|
||||
return None
|
||||
|
||||
|
||||
def _build_model_response(model: ModelConfig) -> "ModelResponse":
|
||||
extra = model.model_extra or {}
|
||||
provider = str(extra["provider"]) if "provider" in extra else _derive_provider(model.use)
|
||||
is_local = bool(extra["is_local"]) if "is_local" in extra else (provider in _LOCAL_PROVIDERS)
|
||||
supports_tool_calling = bool(extra["supports_tool_calling"]) if "supports_tool_calling" in extra else True
|
||||
return ModelResponse(
|
||||
name=model.name,
|
||||
model=model.model,
|
||||
display_name=model.display_name,
|
||||
description=model.description,
|
||||
supports_thinking=model.supports_thinking,
|
||||
supports_reasoning_effort=model.supports_reasoning_effort,
|
||||
supports_vision=model.supports_vision,
|
||||
supports_tool_calling=supports_tool_calling,
|
||||
is_local=is_local,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
class ModelResponse(BaseModel):
|
||||
"""Response model for model information."""
|
||||
@@ -16,6 +57,10 @@ class ModelResponse(BaseModel):
|
||||
description: str | None = Field(None, description="Model description")
|
||||
supports_thinking: bool = Field(default=False, description="Whether model supports thinking mode")
|
||||
supports_reasoning_effort: bool = Field(default=False, description="Whether model supports reasoning effort")
|
||||
supports_vision: bool = Field(default=False, description="Whether model supports image inputs")
|
||||
supports_tool_calling: bool = Field(default=True, description="Whether model supports tool/function calling")
|
||||
is_local: bool = Field(default=False, description="Whether this is a locally-hosted model")
|
||||
provider: str | None = Field(None, description="Model provider name (openai, anthropic, ollama, etc.)")
|
||||
|
||||
|
||||
class TokenUsageResponse(BaseModel):
|
||||
@@ -73,17 +118,7 @@ async def list_models(config: AppConfig = Depends(get_config)) -> ModelsListResp
|
||||
}
|
||||
```
|
||||
"""
|
||||
models = [
|
||||
ModelResponse(
|
||||
name=model.name,
|
||||
model=model.model,
|
||||
display_name=model.display_name,
|
||||
description=model.description,
|
||||
supports_thinking=model.supports_thinking,
|
||||
supports_reasoning_effort=model.supports_reasoning_effort,
|
||||
)
|
||||
for model in config.models
|
||||
]
|
||||
models = [_build_model_response(model) for model in config.models]
|
||||
return ModelsListResponse(
|
||||
models=models,
|
||||
token_usage=TokenUsageResponse(enabled=config.token_usage.enabled),
|
||||
@@ -122,11 +157,4 @@ async def get_model(model_name: str, config: AppConfig = Depends(get_config)) ->
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' not found")
|
||||
|
||||
return ModelResponse(
|
||||
name=model.name,
|
||||
model=model.model,
|
||||
display_name=model.display_name,
|
||||
description=model.description,
|
||||
supports_thinking=model.supports_thinking,
|
||||
supports_reasoning_effort=model.supports_reasoning_effort,
|
||||
)
|
||||
return _build_model_response(model)
|
||||
@@ -97,6 +97,10 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
_DEFAULT_ASSISTANT_ID = "lead_agent"
|
||||
|
||||
# Built-in LangGraph graph IDs registered in langgraph.json.
|
||||
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
|
||||
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent"})
|
||||
|
||||
|
||||
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
||||
# ``body.context`` into the run config. ``config["context"]`` exists in
|
||||
@@ -140,12 +144,15 @@ def merge_run_context_overrides(config: dict[str, Any], context: Mapping[str, An
|
||||
def resolve_agent_factory(assistant_id: str | None):
|
||||
"""Resolve the agent factory callable from config.
|
||||
|
||||
Custom agents are implemented as ``lead_agent`` + an ``agent_id``
|
||||
injected into ``configurable`` or ``context`` — see
|
||||
:func:`build_run_config`. All ``assistant_id`` values therefore map to the
|
||||
same factory; the routing happens inside ``make_lead_agent`` when it reads
|
||||
``cfg["agent_id"]``.
|
||||
Built-in graphs (``lead_agent``, ``canvas_agent``) are dispatched directly
|
||||
to their own factory. Custom agents share the ``lead_agent`` factory with
|
||||
``agent_id`` injected into configurable/context by :func:`build_run_config`.
|
||||
"""
|
||||
if assistant_id == "canvas_agent":
|
||||
from deerflow.agents.canvas_agent.agent import make_canvas_agent
|
||||
|
||||
return make_canvas_agent
|
||||
|
||||
from deerflow.agents.lead_agent.agent import make_lead_agent
|
||||
|
||||
return make_lead_agent
|
||||
@@ -204,8 +211,9 @@ def build_run_config(
|
||||
config["configurable"] = {"thread_id": thread_id}
|
||||
|
||||
# Inject custom agent id when the caller specified a non-default assistant.
|
||||
# Honour an explicit agent_id in the active runtime options container.
|
||||
if assistant_id and assistant_id != _DEFAULT_ASSISTANT_ID:
|
||||
# Built-in graphs (lead_agent, canvas_agent) are dispatched via their own
|
||||
# factory and must NOT receive an agent_id override.
|
||||
if assistant_id and assistant_id not in _BUILTIN_GRAPH_IDS:
|
||||
normalized = assistant_id.strip()
|
||||
if not normalized or not re.fullmatch(r"[A-Za-z0-9_-]+", normalized):
|
||||
raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, hyphens, and underscores.")
|
||||
@@ -225,7 +233,7 @@ def build_run_config(
|
||||
def _collect_requested_agent_ids(body: Any) -> set[str]:
|
||||
"""Collect agent ids from assistant_id/config/context for access checks."""
|
||||
agent_ids: set[str] = set()
|
||||
if body.assistant_id and body.assistant_id != _DEFAULT_ASSISTANT_ID:
|
||||
if body.assistant_id and body.assistant_id not in _BUILTIN_GRAPH_IDS:
|
||||
agent_ids.add(str(body.assistant_id))
|
||||
|
||||
def collect(container: Mapping[str, Any] | None) -> None:
|
||||
|
||||
Reference in new issue
Block a user