-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathChapter4_notes.doc
More file actions
453 lines (324 loc) · 20.6 KB
/
Chapter4_notes.doc
File metadata and controls
453 lines (324 loc) · 20.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
Building a Chatbot
2. 选取合适的AI和如何调用
1. 用bun下载openai 来调用
2.用new OpenAI实例的方法来赋值apiKey(从env file中)
3. OpenAI这个实例 赋值到一个client变量中
接受用户提供的prompt:
1. 使用post function 因为我们不只是接受,还要传输用户给予的prompt给到服务器。
2. 提取用户的req body中的prompt(使用destrcutor的方法得到prompt)
3. 用client.responses.create 方法选择模型,input和其他必要参数
注意:因为是要发送给服务器,所以需要await服务器返回结果。
将得到的结果赋值到response 然后用res.json的方式返回output_text.
adding a middleware function
为什么需要:中间件就是拦截器/处理器,可以在客户端请求和服务端响应的过程中,执行一些逻辑(比如解析数据、日志记录、权限验证等)。
在本节课中app.use(express.json()); 就是中间件
其作用是:
自动把客户端请求体中的 JSON 字符串解析成 JavaScript 对象
这样你就可以直接通过 req.body 访问数据,而不用手动解析
如果这个middleware不存在的话,就会导致prompt这个变量不存在
Testing the API:
1. 我们使用post man作为fake网页发送请求,查看接收到的请求的状态等信息。
2.我们需要接入Open AI的API 其方法如下
首先我们需要去到https://platform.openai.com/docs/models 完成登录和添加fund
比较模型
设置max token来控制输出的窗口长度。
设置temp来控制创意程度,温度越高约混乱
设置top p来决定我们要选择前百分之多少的选项。
temp和top_p都是来控制randomness的,
我们一般只改变其中之一,
如果不确定,我们应该保持top_p为1 调整temp
3. 创建API key:
回归到index.ts中加入环境变量
在powershell中我们应该使用 setx OPENAI_API_KEY "your_api_key_here"
在我们server.env file中我们需要设置我们的API_key,这里是真实的。
在index.ts中我们必须import dotenv from 'dotenv';
以及: dotenv.config(); 来加载 env环境。
总结:环境变量不是加在你的电脑上的,而是在你当前工程文件中,但该工程文件并不会被git上传。
注意:所以你应该同时包括一个.env.example的文件告诉别人如何设置他们自己的.env文件。
4. 此时你就可以用post man的post方法发送请求到 http://localhost:3000/api/chat 使用修改raw为json格式,发送问题得到回复了。
{
"prompt":"what is the capital of France?"
}
Managing Conversation State:
目前我们的APP是没有上下文记忆功能的,本节的内容是让APP可以记住我们之前问了什么问题
方法1:调用回答的ID
设置previous question变量来存储,其type应该是String或者null
每次获得respose之后更新这个变量 last_response_id = response.id
并在每次发送请求体的时候,提供previous_response_id
存在的问题是:我们只能追踪上一个问题,对于所有user。
但现实情况是一个user可以有多个conversation,同时还可以有多个user
方法2:使用Map,或者dictionary
映射关系如下:
conversation 1 -> last_response_id_1
conversation 2 -> last_response_id_2
使用new Map<string, string>();
在请求体(req.body)中我们存在conversationId,使用和获得prompt相同的方式就可以
使用.set方法更新map 键值分别是conversationId,response.id
发送的请求体也变更成为.get方法获取conversionId
在现实中,我们是将这些信息存入数据库,但目前来说不需要。
注:请求体中的变量名都是确定的,而不是随便起的。
比如: previous_response_id:conversation.get(conversationId)
而不是:conversation:conversation.get(conversationId)
Input Validation:
我们不能指望用户能给我们正确的输入,我们需要限制用户
我们需要使用zod。
还是在index中我们import zod
zod的使用只需要设置schema常数,然后用zod的object方法来设置你需要参数的格式信息
比如:是否trim,最短,最长等
之后在post请求中,应该首先检查schema是否safe。
chatSchema.safeParse(req.body)
将得到的对象包装到一个变量里面,然后检查其是否是success的,如果不是则
res.status(400).json({ errors: parseResult.error.format() });
让用户知道其错误在哪。
Error Handling:
我们的服务器会因为各种原因无法访问,但当其出现时,我们不希望看到一个html写满了报错的问题
此时我们就可以使用 try catch方法来捕捉error,并设置标准返回信息。
在try中 我们包含所有需要访问服务器需要进行的程序(不包含zod的schma)
catch中设置返回请求的状态为500标志内部服务器错误
和一个json({error:错误原因})
3.1 Refactoring the BackEnd
为了能够更好的分层,我们需要首先了解back end 到底是如何应该怎么分层
顶层: controllers | gateway 用于接收HTTP请求,和返回 HTTP响应体
中层:services | Application logic 比如叫OPENAI的api就属于这一层的
底层:repositories | data 用于存储数据。
3.2 extracting conversation repository:
对于我们在3.1聊到的layer,我们永远是由上到下,
例如顶层可以与中层对话,但底层无法与顶层对话。
我们本次只调整我们的repository
我们目前的代码中MAP 和更新map的代码就属于repository层的。
首先先提取出MAP这个代码,以及其更新获取方式到server下的repositories文件夹中的conversation.repository.ts中加入环境变量
因为我们这是implementation detail,我们并不需要对外暴露,我们使用的是map方法
因此不需要export整个conversation这个map,而是创建一些新的function,然后export这些function
例如,get,set等
但是如果你只这么做的话,会导致在index这个文件夹中,看代码的人并不知道getLastResponseId和 setLastResponsedID是repository的function
这时候,我们就需要定义一个对象叫做conversationRepository,其中包含这两个function
这样在外部调用的时候会调用这个对象的方法,这样就清晰了。
3.3 Extracting chat service
这一次我们的implement detail就是这个OPENAI的api key调用了
剩下的send Message方法我们可以直接包装进chatService中。
但是需要注意,如果我们就这么结束了,这是一个leaky abstraction
因为在外部调用时,我们会使用response.output_text
这是openAI独有的调用方法
所以为了解决这个问题,我们可以创建一个interface对象
其中包含message和id
然后修改sendMessage方法使其promist可以返回一个ChatResponse函数。
这样的话当我们需要修改LLM模型时,只需要更新return后面message的方法就可以了
3.4 extracting chat Controller
现在,我们希望把arrow function里面剩下的内容单独提取到一个controller中
这样我们只需要post调用这一个方法就可以
同时我们应该调整chat的schema也到这里面
3.5 Extracting Router
现在index.ts还好,没有很多router,但是未来会有很多,所以可以建立一个router file
具体实现方式直接看代码就行,没有什么fancy的。
4.1 & 4.2 handling form submission:
4.1没什么好说的,你让AI来做雏形就行
4.2我们新加入了一个react-hook-form的包来帮助我们submit form
const { register, handleSubmit, reset, formState } = useForm<FormData>();
register就是说我们要交上去什么东西
因为其是一个对象,所以需要...register 这样才可以保证其他属性也被copy
我们需要定义FormData有什么
比如当前 form data就只有prompt其是一个string
看register这个方法,后面可以跟{}来指定需要的validation
handleSubmit方法中需要提供一个arrow function
这个function可以被提取出来,目前我们只需要在console中log 输入的data就可以了
同时记得要reset
在提交form时,我们希望按下回车键(不同时按下shift)会自动提交
此时就需要用到onKeyDown方法
我们也可以将onKeyDown提取出来,但是需要明确e的类型,因为它不确定自己属于哪里了
同时 default行为是 按下回车就会自动开启新的一行,这一点是我们不希望的。
所以prevent
在button部分,我们希望如果formState不是valid就不开放
4.3 calling the BackEnd
调用axios的post方法,传递给指定url需要的信息,这里是prompt和conversationID
注意:因为是要发送给服务器,所以需要async,同时让reset提前
conversationID需要创建独立Ref,所以要用这个方式创建
const conversationId = useRef(crypto.randomUUID());
调用时,使用.current
4.4 rendering the message
要用到useState 变量,记住OpenAI给的回复是string array
那么我们setMessage就应该是({...message, prompt})
我们得到后,用setMessage中继承之前的聊天...message
然后加载上,data.message
我们直接data.message是无法显示出来的,因为ts不知道这个post function返回的data包含什么东西
所以我们可以在上面定义一个type叫做chatResponse,然后其中包含一个属性message 类型是string
我们还需要让我们的message可以显示,所以用shift+contrl+p的方式将之前的代码包含在一个div中
然后在这个div的上方,建立一个新的div,其中
<div>
{message.map((msg, index) => (
<p key={index}>{msg}</p>
))}
</div>
让每个message都可以显示出来,且key就是他们的index
但是server的response会覆盖,我们之前的回复,因为...message每次都是空的
所以我们要用arrow function来指定谁是prev
ChatGPT解释:
非函数式写法(setMessage([...message, something]))
就好像你拍了一张 旧照片(当时的 message),
然后在这张照片上涂一笔,把它交给 React。
等 React 真正去用的时候,它只能用这张旧照片,
所以如果你再交一次“旧照片 + 另一笔”,
React 还是基于旧照片,前一笔就会被覆盖掉。
函数式写法(setMessage(prev => [...prev, something]))
你不是交“旧照片”,
而是交一条 加工说明书:“拿到最新的照片,再加一笔”。
React 每次要更新时,都会去看最新的照片,
然后按照你的说明书再画上去。
这样无论更新多少次,之前的笔迹都不会丢。
这与react的强制刷新有关系,react并不是在你set完后就会更新state,而是在每次render后。
所以你这次设置了message,然后在当前这个loop中,你发给服务器的是没有设置过的。所以服务器会覆盖你的回复
setMessage([...message, prompt]) // 把“[prompt]”放进更新队列;闭包里的 message 仍是 []
await axios.post(...) // 暂停;闭包里的 message 仍是 []
setMessage([...message, data]) // 仍基于 [] 计算出 [data] → 覆盖掉 [prompt]
4.5 Styling messages:
我们现在看起来不像是一个conversation。所以我们让我们的prompt在左边,回复在右边
设置一个message类型,content就是string
但我们设置role:user或者bot
所以此时message更新的就是message state了,他需要同时更新content和role
那么当set的时候,message就是{prompt和role: “——————”}
更新HTML的部分,我们更新的就是message.content了。
也给其一个className的css
比如px-3 py-1 然后根据role来决定背景和位置
使用${
来写你的js code(判断role和决定颜色 和位置)
}
同时为了让回复和问题不粘在一起,我们需要flex 和 gap在之前的div。
4.6:Rendering Markdown text:
有一些text openAI回复你会是markdown text,所以我们需要正确的mark down格式显示
我们要先下载react-markdown的包来帮助我们 然后import
然后将我们的message包裹在这个
<ReactMarkdown>{msg.content}</ReactMarkdown>
这样就可以正常显示markdown了
4.7 adding a typing indicator:
当我们给一个比较难的问题给服务器的时候,我们会发现,其需要一定时间来给我们反馈。
所以我们应该增加一个 bot正在输入的动画,比如三个点,让这三个点间隔0.2秒呈现
首先用useState 创建一个boolean的变量,叫做isBotTypeing来indicating 是否bot正在输入
那么这个的判断时间就是,如果我已经发给服务器了,那么bot就是正在输入
当服务器给我返回结果,就是bot输入结束。
之后,在第一个div render message的部分我们应该再增加一个判断,是否bot type
如果是的话,则显示三个点,代码不长直接写在这里:
{isBotTyping && (
<div className="flex gap-1 px-3 py-3 bg-gray-200 rounded-xl self-start">
<div className="w-2 h-2 rounded-full bg-gray-800 animate-pulse"></div>
<div className="w-2 h-2 rounded-full bg-gray-800 animate-pulse [animation-delay:0.2s]"></div>
<div className="w-2 h-2 rounded-full bg-gray-800 animate-pulse [animation-delay:0.4s]"></div>
</div>
)}
</div>
需要注意的是,因为我们这个判断是被包含在对话这个div中间的,所以我们为了不让三个点的动画占据整个屏幕,应该在这三个点的动画上增加self-start。
4.8: auto_scrolling to the last Message:
当我们现在对话超出一页后,不会自动滚动到下一页。所以我们这一节来修改这个问题。
我们可以用use effect方法,
首先把ref设置成我们的form
这个ref就会告诉计算机我们要检查哪个元素是否可见?
(
在我们的之前代码中,回复处于一个单独的图层中,因为我们有个消息内滚动
max-h-[500px] overflow-y-auto
所以去除了
)
然后使用useEffct,来检查formRef.current
formRef 是通过 useRef<HTMLFormElement>(null) 创建的“引用容器对象”。它长这样:
{ current: null }
一开始 current 是 null。
ref={formRef} 写在 <form> 元素上以后,React 在组件渲染时会把这个 DOM 节点塞进来:
formRef.current = <form> 对应的真实 DOM 元素
formRef.current 就表示“当前 React 已经挂到这个 ref 上的实际 DOM 元素”。
所以 formRef.current?.scrollIntoView(...) 这句的意思就是:
如果这个 <form> 已经在页面里存在了(即 current 不为 null),就调用它的 scrollIntoView 方法。
4.9 improving copy behaviour
现在当我们copy的时候,会copy下来很多HTML的元素(不显示)。
所以我们可以设置onCopy function来trim。
首先阻止默认逻辑:直接放入剪切板
再给我们之前得到的section 放回我们的clipboard(setData function)
在这里,我们并没有明确事件目标是谁,也就是说谁来都一样。
但是我们在其他的地方,可以用<>的方式来指定我们只看谁
比如
if (e.target instanceof HTMLParagraphElement) {
// 只允许从 <p> 标签复制
}
知道 target 的作用在于:
让复制逻辑更有针对性(不同元素,不同处理)。
提高用户体验和安全性。
4.10 Imporving the look and feel.
1. 将输入框push到页面最下面
只需要将页面设置成flex vertical container
让message 得到所有的空间。
目前无事发生,因为我们没有指定height。
所以我们再增加hight (h-screen)
但是这种是hard code
但有时我们希望它占据一整页
有时是 side bar 之类的
1. 所以应该在外部管控高度,比如app component
2. 然后在内部的chatBot中我们应该设置高度为h-full 这样就可以让chatBot填充整个外部元素了
同理 weight也一样,w-2xl也在 app中,然后内部是w-full
2. 我们只希望 内容部分滚动,而不是整个页面滚动。
但auto scrolling broke了,所以我们加入哨兵<p>更换ref
3. console中产生了报错,因为p中不能包含ol元素
这个问题产生的原因是因为我们的服务器返回的对话是ol,那么前端用p来接受是不合适的
所以我们将p更改成为div
同时更改哨兵节点类型
4.用户进入这个页面时,不会自动focus到对话框
5. copy后如果不更新,rest不会启动
给个参数,看prompt是否更新就行
reset({prompt: ''});
4.11 Handling error:
现在如果我们的后端服务器坏了,我们无法知道。要解决这个问题,我们可以增加 try Catch和finally
Try来尝试运行代码块
catch是捕捉任何错误
finally是在try时,我们有时会设置一些变量,用finally将他们改回默认值
我们还需要设置出错误后的提示,用useState来更新
这就是string和null就可以
然后catch的话就把报错信息写入error中
显示的判断逻辑就放在message的动画后面就可以。
记住,你需要在每次onSubmit的时候制空我们的setError
5.1 Refactoring
本章节中 我们将要refactoring我们的前端
前端分离并没有标准模板,但是我们应该让其更简洁
因此拆分成下面几个块:
1. chatInput:管理所有输入的内容
2. chatMessage:管理服务器返回内容
3. Typing indicator:不属于上面任何一个状态,所以应该单独拆开
4. ChatBot:最后的乐高完整拼图
5.2 extracting TypingIndicator component:
改的东西并不多,首先我们创建了一个chat文件夹,并把所有跟chat有关的文件都放入了
其次我们重复了很多次dot的format,所以可以抽象成一个type,
用创建const的方法,手动的添加className。
具体方法可以去看TypingIndictor.tsx
5.3 Extracting chat Message:
chatMessage内容还挺多的因为其耦合度高
rafce 快捷方式创建一个file
1. 先将html那部分抽离出来,直接赋值到chatMessage的return中
2. import必须的包。
3. 将onCopy部分直接抄下来,记住不能直接给ClipboardEvent,而是用react的方法
因为react会包装一次,确保onCopy有内容
4.将use Effect也抓过来
5. 此时你会发现对话框占据了整个屏幕,因为我们return的时候 是个div,div自动占据整个页面
所以你需要加上flex,flex-col
6.在之前的chatBot文件中,需要将拿走的Message[] import回来
记住使用Props,不然会出问题,使用方法如下:
export type Message = {
role: 'user' | 'bot';
content: string;
};
type Props = {
messages: Message[];
};
const ChatMessage = ({ messages }: Props) => {...}
5.4 Extracting ChatInput component:
1.将HTML的部分取出
2.formState 部分报错,也拿过来
此时你会发现我们没有formData
所以也拿过来
但formData与源文件名称类似,所以更改成为ChatFormData
3. 此时我们来解决onSubmit问题
我们不可能把on Submit的方法全部给的form里面
因为这与它关系并不大。所以我们只挪动reset
4. reset form和执行onSubmit方法
我们告诉父组件,我们有一个新的prompt进来了
所以让父组件告诉我onSubmit要干嘛
之后让父组件给我props就行。
5.对于onKeyDown方法,我们直接复制
但记住,你不是调用handleSubmit方法了,而是直接调用这个input function中的submit方法。
这样才会刷新你的输入区域。
6. 我个人觉得我的error也挺长的,就给它也提取出来了
其需要error和setError 我也直接让父组件给我提供了
总结一下:前端的最顶端代码应该像一个manager一样,把自己的手下(useState)发送到不同的部门中