Skip to content

Commit 6871688

Browse files
committed
feat(ai): 工具调用
1 parent 60b46d8 commit 6871688

File tree

4 files changed

+167
-85
lines changed

4 files changed

+167
-85
lines changed

app/README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,6 @@ For advanced features like compute engine, see [Full Documentation](https://proj
7171

7272
[慕乐云](https://muleyun.com/aff/HLONILNH)
7373

74-
AD: 慕乐云的年度活动太便宜了xD,8c8g 30G系统盘 80G数据盘 只要480CNY/年
75-
7674
[YXVM](https://yxvm.com/)
7775

7876
![yxvm](https://beisudianxueuser.oss-cn-beijing.aliyuncs.com/storage/user_avatar/ciallo/2025/04/06/1818c0770e94a4257af5eb7d5530f5fd/Screenshot%202025-04-06%20at%2016-23-03%20NodeSupport%20Promotion.png)

app/src/core/service/dataManageService/aiEngine/AITools.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
import { Project } from "@/core/Project";
2+
import { TextNode } from "@/core/stage/stageObject/entity/TextNode";
3+
import { Color } from "@graphif/data-structures";
4+
import { serialize } from "@graphif/serializer";
15
import OpenAI from "openai";
26
import z from "zod/v4";
37

48
export namespace AITools {
59
export const tools: OpenAI.ChatCompletionTool[] = [];
6-
export const handlers: Map<string, (...args: any[]) => any> = new Map();
10+
export const handlers: Map<string, (project: Project, data: any) => any> = new Map();
711

8-
function addTool<A extends z.ZodType>(name: string, description: string, parameters: A, fn: (...args: any) => any) {
12+
function addTool<A extends z.ZodObject>(
13+
name: string,
14+
description: string,
15+
parameters: A,
16+
fn: (project: Project, data: z.infer<A>) => any,
17+
) {
918
tools.push({
1019
type: "function",
1120
function: {
@@ -18,5 +27,31 @@ export namespace AITools {
1827
handlers.set(name, fn);
1928
}
2029

21-
addTool("get_all_nodes", "获取所有节点", z.object({}), () => {});
30+
addTool("get_all_nodes", "获取所有节点以及uuid", z.object({}), (project) => serialize(project.stage));
31+
addTool("delete_node", "根据uuid删除节点", z.object({ uuid: z.string() }), (project, { uuid }) => {
32+
project.stageManager.delete(project.stageManager.get(uuid)!);
33+
project.historyManager.recordStep();
34+
});
35+
addTool(
36+
"edit_text_node",
37+
"根据uuid编辑TextNode",
38+
z.object({
39+
uuid: z.string(),
40+
data: z.object({
41+
text: z.string().optional(),
42+
color: z.array(z.number()).optional().describe("[255,255,255,1]"),
43+
x: z.number().optional(),
44+
y: z.number().optional(),
45+
width: z.number().optional(),
46+
height: z.number().optional(),
47+
}),
48+
}),
49+
(project, { uuid, data }) => {
50+
const node = project.stageManager.get(uuid);
51+
if (!(node instanceof TextNode)) return;
52+
node.text = data.text ?? node.text;
53+
node.color = data.color ? new Color(...(data.color as [number, number, number, number])) : node.color;
54+
project.historyManager.recordStep();
55+
},
56+
);
2257
}

app/src/core/stage/Canvas.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export class Canvas {
3636
}
3737
if (
3838
document.activeElement?.tagName === "INPUT" ||
39+
document.activeElement?.tagName === "TEXTAREA" ||
3940
document.activeElement?.getAttribute("contenteditable") === "true"
4041
) {
4142
// 如果当前焦点在输入框上,则不处理键盘事件

app/src/sub/AIWindow.tsx

Lines changed: 128 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,25 @@
11
import Markdown from "@/components/ui/markdown";
2+
import { Textarea } from "@/components/ui/textarea";
3+
import { AITools } from "@/core/service/dataManageService/aiEngine/AITools";
24
import { Settings } from "@/core/service/Settings";
35
import { SubWindow } from "@/core/service/SubWindow";
46
import { activeProjectAtom } from "@/state";
57
import SettingsWindow from "@/sub/SettingsWindow";
68
import { Vector } from "@graphif/data-structures";
79
import { Rectangle } from "@graphif/shapes";
810
import { useAtom } from "jotai";
9-
import { Bot, FolderOpen, Loader2, Send, SettingsIcon, User } from "lucide-react";
11+
import { Bot, FolderOpen, Loader2, Send, SettingsIcon, User, Wrench } from "lucide-react";
1012
import OpenAI from "openai";
1113
import { useRef, useState } from "react";
1214

1315
export default function AIWindow() {
1416
const [project] = useAtom(activeProjectAtom);
1517
const [inputValue, setInputValue] = useState("");
1618
const [messages, setMessages] = useState<(OpenAI.ChatCompletionMessageParam & { tokens?: number })[]>([
17-
// {
18-
// role: "system",
19-
// content: `\
20-
// **角色:** Project Graph AI (首席工程师,严格遵循 ReAct 模式)
21-
// **核心原则:**
22-
// 1. **深度优先探索:** 系统性探索用户视野内节点及其父子节点,理解内容、结构、关系。
23-
// 2. **基于事实行动:** 收集充分上下文前不修改节点。分析工具输出,留意新节点/关系线索。
24-
// 3. **最小有效修改:** 仅进行完成任务必需的最少更改,保持图结构整洁。
25-
// 4. **高度自主:** 优先使用工具解决问题。仅在多次尝试失败且信息关键时请求用户输入。
26-
// 5. **专注任务:** 避免无关对话,专注迭代(思考->行动->观察)直至任务完成。
27-
// **关键指令:**
28-
// * **ReAct 循环:** 推理->调用工具->观察结果->迭代。
29-
// * **工具输出关键:** 仔细分析所有输出,主动探索输出中提到的新相关节点。
30-
// * **根因分析:** 识别问题节点及其潜在关联节点(可能为根本原因)。
31-
// `,
32-
// },
19+
{
20+
role: "system",
21+
content: "尽可能尝试使用工具解决问题,如果实在不行才能问用户",
22+
},
3323
]);
3424
const [requesting, setRequesting] = useState(false);
3525
const [totalInputTokens, setTotalInputTokens] = useState(0);
@@ -40,11 +30,11 @@ export default function AIWindow() {
4030
function addMessage(message: OpenAI.ChatCompletionMessageParam & { tokens?: number }) {
4131
setMessages((prev) => [...prev, message]);
4232
}
43-
function setLastMessageContent(content: string) {
33+
function setLastMessage(msg: OpenAI.ChatCompletionMessageParam) {
4434
setMessages((prev) => {
45-
const lastMessage = prev[prev.length - 1];
46-
if (!lastMessage) return prev;
47-
return [...prev.slice(0, -1), { ...lastMessage, content } as any];
35+
const newMessages = [...prev];
36+
newMessages[newMessages.length - 1] = msg;
37+
return newMessages;
4838
});
4939
}
5040

@@ -54,42 +44,89 @@ export default function AIWindow() {
5444
}
5545
}
5646

57-
async function send() {
47+
async function run(msgs: OpenAI.ChatCompletionMessageParam[] = [...messages, { role: "user", content: inputValue }]) {
5848
if (!project) return;
5949
scrollToBottom();
6050
setRequesting(true);
61-
setInputValue("");
62-
const msgs: OpenAI.ChatCompletionMessageParam[] = [
63-
...messages,
64-
{
65-
role: "user",
66-
content: inputValue,
67-
},
68-
];
69-
addMessage({
70-
role: "user",
71-
content: inputValue,
72-
});
73-
addMessage({
74-
role: "assistant",
75-
content: "Requesting...",
76-
});
77-
const stream = await project.aiEngine.chat(msgs);
78-
let streamingMsg = "";
79-
let lastChunk: OpenAI.ChatCompletionChunk | null = null;
80-
for await (const chunk of stream) {
81-
const delta = chunk.choices[0].delta;
82-
streamingMsg += delta.content;
83-
setLastMessageContent(streamingMsg);
51+
try {
52+
const stream = await project.aiEngine.chat(msgs);
53+
addMessage({
54+
role: "assistant",
55+
content: "Requesting...",
56+
});
57+
const streamingMsg: OpenAI.ChatCompletionAssistantMessageParam = {
58+
role: "assistant",
59+
content: "",
60+
tool_calls: [],
61+
};
62+
let lastChunk: OpenAI.ChatCompletionChunk | null = null;
63+
for await (const chunk of stream) {
64+
const delta = chunk.choices[0].delta;
65+
streamingMsg.content! += delta.content ?? "";
66+
const toolCalls = delta.tool_calls || [];
67+
for (const toolCall of toolCalls) {
68+
if (typeof streamingMsg.tool_calls !== "undefined") {
69+
const index = streamingMsg.tool_calls.length;
70+
if (!streamingMsg.tool_calls[index]) {
71+
streamingMsg.tool_calls[index] = {
72+
...toolCall,
73+
// Google AI 不会返回工具调用的 id
74+
// https://discuss.ai.google.dev/t/tool-calling-with-openai-api-not-working/60140/5
75+
id: toolCall.id || crypto.randomUUID(),
76+
} as any;
77+
} else if (toolCall.function) {
78+
streamingMsg.tool_calls[index].function.arguments += toolCall.function.arguments;
79+
}
80+
}
81+
}
82+
setLastMessage(streamingMsg);
83+
scrollToBottom();
84+
lastChunk = chunk;
85+
}
86+
setRequesting(false);
87+
if (!lastChunk) return;
88+
if (!lastChunk.usage) return;
89+
setTotalInputTokens((v) => v + lastChunk.usage!.prompt_tokens);
90+
setTotalOutputTokens((v) => v + lastChunk.usage!.completion_tokens);
8491
scrollToBottom();
85-
lastChunk = chunk;
92+
// 如果有工具调用,执行工具调用
93+
if (streamingMsg.tool_calls && streamingMsg.tool_calls.length > 0) {
94+
const toolMsgs: OpenAI.ChatCompletionToolMessageParam[] = [];
95+
for (const toolCall of streamingMsg.tool_calls) {
96+
const tool = AITools.handlers.get(toolCall.function.name);
97+
if (!tool) {
98+
return;
99+
}
100+
let observation = "";
101+
try {
102+
const result = await tool(project, JSON.parse(toolCall.function.arguments));
103+
if (typeof result === "string") {
104+
observation = result;
105+
} else if (typeof result === "object") {
106+
observation = JSON.stringify(result);
107+
} else {
108+
observation = String(result);
109+
}
110+
} catch (e) {
111+
observation = `工具调用失败:${(e as Error).message}`;
112+
}
113+
const msg = {
114+
role: "tool" as const,
115+
content: observation,
116+
tool_call_id: toolCall.id!,
117+
};
118+
addMessage(msg);
119+
toolMsgs.push(msg);
120+
}
121+
// 工具调用结束后,重新发送消息,让模型继续思考
122+
run([...msgs, streamingMsg, ...toolMsgs]);
123+
}
124+
} catch (e) {
125+
addMessage({
126+
role: "assistant",
127+
content: String(e),
128+
});
86129
}
87-
setRequesting(false);
88-
if (!lastChunk) return;
89-
if (!lastChunk.usage) return;
90-
setTotalInputTokens((v) => v + lastChunk.usage!.prompt_tokens);
91-
setTotalOutputTokens((v) => v + lastChunk.usage!.completion_tokens);
92-
scrollToBottom();
93130
}
94131

95132
return project ? (
@@ -98,43 +135,54 @@ export default function AIWindow() {
98135
{messages.map((msg, i) =>
99136
msg.role === "user" ? (
100137
<div key={i} className="flex justify-end">
101-
<div className="max-w-11/12 rounded-2xl rounded-br-none px-3 py-2">{msg.content as string}</div>
138+
<div className="max-w-11/12 bg-accent text-accent-foreground rounded-2xl rounded-br-none px-3 py-2">
139+
{msg.content as string}
140+
</div>
102141
</div>
103142
) : msg.role === "assistant" ? (
104-
<div key={i}>
105-
<Markdown source={msg.content as string} />
143+
<div key={i} className="flex flex-col gap-2">
144+
{msg.content && <Markdown source={msg.content as string} />}
145+
{msg.tool_calls &&
146+
msg.tool_calls.map((toolCall) => (
147+
<div className="flex items-center gap-1 text-xs" key={toolCall.id}>
148+
<Wrench size={16} />
149+
{toolCall.function.name}
150+
{/*{toolCall.function.arguments}*/}
151+
</div>
152+
))}
106153
</div>
107154
) : (
108155
<></>
109156
),
110157
)}
111158
</div>
112-
<div className="flex flex-col gap-2 rounded-xl border p-2">
113-
<div className="flex gap-2">
114-
<SettingsIcon className="cursor-pointer" onClick={() => SettingsWindow.open("settings")} />
115-
{showTokenCount && (
116-
<>
117-
<div className="flex-1"></div>
118-
<User />
119-
<span>{totalInputTokens}</span>
120-
<Bot />
121-
<span>{totalOutputTokens}</span>
122-
</>
123-
)}
124-
<div className="flex-1"></div>
125-
{requesting ? (
126-
<Loader2 className="animate-spin" />
127-
) : (
128-
<Send className="cursor-pointer" onClick={() => send()} />
129-
)}
130-
</div>
131-
<textarea
132-
className="cursor-text outline-none"
133-
placeholder="What can I say?"
134-
onChange={(e) => setInputValue(e.target.value)}
135-
value={inputValue}
136-
/>
159+
<div className="mb-2 flex gap-2">
160+
<SettingsIcon className="cursor-pointer" onClick={() => SettingsWindow.open("settings")} />
161+
{showTokenCount && (
162+
<>
163+
<div className="flex-1"></div>
164+
<User />
165+
<span>{totalInputTokens}</span>
166+
<Bot />
167+
<span>{totalOutputTokens}</span>
168+
</>
169+
)}
170+
<div className="flex-1"></div>
171+
{requesting ? (
172+
<Loader2 className="animate-spin" />
173+
) : (
174+
<Send
175+
className="cursor-pointer"
176+
onClick={() => {
177+
if (!inputValue.trim()) return;
178+
addMessage({ role: "user", content: inputValue });
179+
setInputValue("");
180+
run();
181+
}}
182+
/>
183+
)}
137184
</div>
185+
<Textarea placeholder="What can I say?" onChange={(e) => setInputValue(e.target.value)} value={inputValue} />
138186
</div>
139187
) : (
140188
<div className="flex flex-col gap-2 p-8">

0 commit comments

Comments
 (0)