可上传图片分析的AI对话框
本节内容可以参考文件"CaseStudy\LLMCaller.Table"的窗口"可上传图片分析的AI对话框"。
本节的任务是设计在之前设计的"AI对话框"窗口的基础,增加图片 上传分析的功能:

设计步骤:
1、窗口的AfterLoad事件代码如下,代码几乎不变,只是增加一个另一个JArray类型的Var变量fileIds,用于存储用户上传的文件id,另密钥也存储在一个Var变量中,因为上传文件也要使用密钥:
'''Async
' 1、初始化LLMCaller
Vars("apikey")
=
"sk-a734b83536da45e9aedc3e42605bfb3a"
'请替换为自己的密钥
Dim
caller
As
New
LLMCaller("https://api.deepseek.com/chat/completions",
"deepseek-flash",
Vars("apikey"))
caller.stream =
True
'开启流式输出
caller.Messages =
New
JArray()
'开启多轮对话
caller.SystemPrompt =
"你是一个乐于助人的助手"
'设置系统提示词
Vars("caller")
= caller
'将初始化好的LLMCaller存放在Var变量中,方便复用
Vars("fileIds")
=
New
JArray()
'用于存储上传文件ID的数组
' 2. 初始化
WebViewer
Dim
wv
As
WebViewer = e.Form.Controls("WebViewer1").WebViewer
Await
wv.EnsureCoreWebView2Async(Nothing)
' 3.
注入
FoxLib(用于后续可能的扩展,如调用
Foxtable 功能)
Dim
ft
As
New
FoxLib(wv)
wv.CoreWebView2.AddHostObjectToScript("ft",
ft)
' 4. 加载聊天界面
HTML
Dim
html
As
String
=
<![CDATA[
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>DeepSeek
会话</title>
<script src="https://cdn.jsdmirror.com/npm/marked/marked.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; font-family:
'Microsoft YaHei', sans-serif; }
body { background: #f5f7fa; padding: 20px; height: 100vh; display: flex;
flex-direction: column; }
#chatContainer { flex: 1; overflow-y: auto; background: #fff;
border-radius: 8px; padding: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 15px; }
.message { margin: 10px 0; padding: 10px 14px; border-radius: 18px;
max-width: 80%; word-wrap: break-word; line-height: 1.6; clear: both; }
.message-user { background: #e1f5fe; float: right;
border-bottom-right-radius: 4px; color: #01579b; }
.message-assistant { background: #f0f0f0; float: left;
border-bottom-left-radius: 4px; color: #333; }
.message-error { background: #ffebee; float: left;
border-bottom-left-radius: 4px; color: #c62828; border: 1px solid #ef9a9a; }
/*
新增思考区块样式,区分背景
*/
.think-block {
background: #fff8e6;
border-radius: 12px;
padding: 8px 12px;
margin-bottom: 8px;
font-size: 14px;
color: #805b00;
}
.answer-block {
padding-top: 6px;
}
.message::after { content: ""; display: table; clear: both; }
.timestamp { font-size: 0.75em; color: #999; margin-top: 4px;
text-align: right; }
#emptyHint { color: #aaa; text-align: center; padding: 50px 0;
font-size: 16px; }
/* 新增:Markdown
渲染样式
*/
.answer-block table { border-collapse: collapse; margin: 8px 0; }
.answer-block th, .answer-block td { border: 1px solid #ccc; padding:
4px 10px; text-align: left; }
.answer-block th { background: #f0f0f0; }
.answer-block code { background: #f4f4f4; padding: 2px 6px;
border-radius: 4px; font-family: monospace; font-size: 0.9em; }
.answer-block pre { background: #f6f8fa; border: 1px solid #e1e4e8;
border-radius: 6px; padding: 16px; overflow-x: auto;
font-family: 'Consolas', 'Courier New', monospace; font-size: 14px;
line-height: 1.6; margin: 12px 0; }
.answer-block blockquote { border-left: 4px solid #ddd; padding-left:
14px; margin: 8px 0; color: #555; }
.answer-block ul, .answer-block ol { padding-left: 20px; margin: 6px 0;
}
.answer-block p { margin: 4px 0; }
</style>
</head>
<body>
<div id="chatContainer">
<div id="emptyHint">💬
输入问题开始对话...</div>
</div>
<script>
var msgDiv;
var thinkBox;
var answerBox;
let fullThink = "";
let fullAnswer = "";
//
添加消息到聊天区域,区分user/assistant两种渲染结构
function addMessage(role, content) {
console.log('content:'+ content);
var container = document.getElementById('chatContainer');
var hint = document.getElementById('emptyHint');
if (hint) hint.remove();
msgDiv = document.createElement('div');
msgDiv.className = 'message message-' + role;
//
用户消息:直接显示文本,不创建思考、回答分块
if(role === "user"){
var textNode = document.createElement('div');
textNode.innerHTML = content;
msgDiv.appendChild(textNode);
}
// AI助手消息:创建独立思考、回答两块容器
else if(role === "assistant"){
thinkBox = document.createElement('div');
thinkBox.className = 'think-block';
thinkBox.innerHTML = '<strong>思考过程:</strong><br>';
fullThink = "";
answerBox = document.createElement('div');
answerBox.className = 'answer-block';
answerBox.innerHTML = '<strong>回答:</strong><br>';
fullAnswer = "";
msgDiv.appendChild(thinkBox);
msgDiv.appendChild(answerBox);
}
//
时间戳
var time = new Date();
var timeStr = time.toLocaleTimeString('zh-CN', { hour: '2-digit',
minute: '2-digit' });
var ts = document.createElement('div');
ts.className = 'timestamp';
ts.textContent = timeStr;
msgDiv.appendChild(ts);
container.appendChild(msgDiv);
container.scrollTop = container.scrollHeight;
}
//追加思考内容
function appendThink(content) {
if(msgDiv){
fullThink += content;
thinkBox.innerHTML = '<strong>思考过程:</strong><br>'
+ fullThink;
var container = document.getElementById('chatContainer');
container.scrollTop = container.scrollHeight;
}
}
//追加回答内容
function appendAnswer(content) {
if(msgDiv){
fullAnswer += content;
//
唯一改动:使用
marked.parse 渲染
Markdown
answerBox.innerHTML = '<strong>回答:</strong><br>'
+ marked.parse(fullAnswer);
var container = document.getElementById('chatContainer');
container.scrollTop = container.scrollHeight;
}
}
function addTempMessage(content) {
var container = document.getElementById('chatContainer');
var hint = document.getElementById('emptyHint');
if (hint) hint.remove();
var msgDiv = document.createElement('div');
msgDiv.className = 'message message-assistant';
msgDiv.setAttribute('data-temp', 'true');
var textNode = document.createElement('div');
textNode.innerHTML = content;
msgDiv.appendChild(textNode);
container.appendChild(msgDiv);
container.scrollTop = container.scrollHeight;
}
function removeTempMessage() {
var temp = document.querySelector('.message[data-temp="true"]');
if (temp) temp.remove();
}
//
滚动到底部(可选)
function scrollToBottom() {
var container = document.getElementById('chatContainer');
if (container) container.scrollTop = container.scrollHeight;
}
</script>
</body>
</html>
]]>.Value
wv.NavigateToString(html)
2、新增的上传按钮的代码为:
'''Async
Dim
fileIds
As
JArray = Vars("fileIds")
Dim
dlg
As
New
OpenFileDialog()
dlg.Multiselect =
True
dlg.Filter =
"图片文件|*.png;*.jpg;*.jpeg;*.gif;*.webp|"
&
"PNG 图片
(*.png)|*.png|"
&
"JPEG 图片
(*.jpg;*.jpeg)|*.jpg;*.jpeg|"
&
"GIF 图片
(*.gif)|*.gif|"
&
"WebP 图片
(*.webp)|*.webp"
If
dlg.ShowDialog() = DialogResult.OK
Then
Dim
lstFiles
As
WinForm.ListBox = e.Form.Controls("lstFiles")
lstFiles.Items.Clear()
For
Each
fileName
As
String
In
dlg.FileNames
lstFiles.Items.Add(fileName)
Next
e.Sender.Text =
"上传中..."
e.Sender.Enabled =
False
e.Form.Controls("btnClearFiles").Enabled
=
False
e.Form.Controls("btnSend").Enabled
=
False
For
Each
filePath
As
String
In
dlg.FileNames
Dim
hc
As
New
HttpClient("https://api.deepseek.com/files")
hc.SkipError =
True
hc.Headers.Add("Authorization",
"Bearer "
& Vars("apikey"))
'设置密钥
hc.FormData.Add("purpose",
"user_data")
hc.FormData.Add("expires_after[anchor]",
"created_at")
'
锚点:创建时间
hc.FormData.Add("expires_after[seconds]",
"86400")
'
有效期86400秒,也就是24小时,24小时候服务器会自动删除这个文件
hc.Files.Add("file",
filePath)
Dim
res
As
String
=
Await
hc.GetDataAsync()
If
hc.StatusCode = 200
Then
Dim
jo
As
JObject = JsonConvert.DeserializeObject(res)
fileIds.Add(jo("id").ToString())
Else
MessageBoxA.Show("文件上传失败:"
& filePath & vbCrLf &
"状态码:"
& hc.StatusCode & vbCrLf &
"状态描述:"
& hc.StatusDescription)
End
If
Next
e.Sender.Text =
"上传"
e.Sender.Enabled =
True
e.Form.Controls("btnClearFiles").Enabled
=
True
e.Form.Controls("btnSend").Enabled
=
True
End
If
3、新增的清除按钮代码为:
e.Form.Controls("lstFiles").Items.Clear
Vars("fileIds")
=
New
JArray()
4、发送按钮的代码改为:
'''Async
Dim
wv
As
WebViewer = e.Form.Controls("WebViewer1").WebViewer
Dim
inputBox
As
WinForm.TextBox = e.Form.Controls("TextBox1")
Dim
userMsg
As
String
= inputBox.Text.Trim()
'获取用户输入的问题
If
userMsg =
""
Then
Return
inputBox.Text =
""
'清空输入框
' ---- 1、设置用户问题
----
Dim
caller
As
LLMCaller = Vars("caller")
' 判断是否有上传的文件
Dim
fileIds
As
JArray =
Nothing
If
Vars("fileIds")
IsNot
Nothing
Then
fileIds = Vars("fileIds")
End
If
If
fileIds
IsNot
Nothing
AndAlso
fileIds.Count > 0
Then
'
有文件:构造包含文本和文件的内容数组
Dim
msgArray
As
New
JArray()
msgArray.Add(New
JObject
From
{
{"type",
"text"},
{"text",
userMsg}
})
For
Each
fileId
As
JToken
In
fileIds
msgArray.Add(New
JObject
From
{
{"type",
"file"},
{"file_id",
fileId.ToString()}
})
Next
caller.UserMessage = msgArray
e.Form.Controls("btnClearFiles").PerformClick()
'模拟单击清除按钮,清除上传的文件
Else
'
没有文件:使用纯文本
caller.UserMessage = userMsg
End
If
' ---- 2.
显示用户消息
----
Dim
userMsgEncoded
As
String
= HTMLEncode(userMsg).Replace(vbCrLf,
"<br>").Replace(vbLf,
"<br>")
If
fileIds
IsNot
Nothing
AndAlso
fileIds.Count > 0
Then
userMsgEncoded &=
"<br><em>📁附带了
"
& fileIds.Count &
"
个文件</em>"
End
If
Dim
userMsgJson
As
String
= JsonConvert.ToString(userMsgEncoded)
Await
wv.ExecuteScriptAsync("addMessage('user',
"
& userMsgJson &
")")
Await
wv.ExecuteScriptAsync("addTempMessage('⏳
正在思考中...')")
' ----
3.
定义接收流式输出的回调函数,用于显示推理过程和答案
----
Dim
isFirst
As
Boolean
=
True
caller.SSEStreamHandler =
Async
Sub(ttype
As
String,
tcontent
As
String)
Dim
safeContent
As
String
= tcontent
If
ttype =
"think"
OrElse
ttype =
"error"
Then
safeContent = HtmlEncode(tcontent).Replace(vbCrLf,
"<br>").Replace(vbLf,
"<br>")
ElseIf
ttype =
"answer"
Then
'答案交给Markdown去处理即可
End
If
Dim
safeContentJson
As
String
= JsonConvert.ToString(safeContent)
If
isFirst
Then
isFirst =
False
Await
wv.ExecuteScriptAsync("removeTempMessage()")
Await
wv.ExecuteScriptAsync("addMessage('assistant',
'')")
End
If
If
ttype =
"think"
OrElse
ttype =
"error"
Then
Await
wv.ExecuteScriptAsync("appendThink("
& safeContentJson &
")")
ElseIf
ttype =
"answer"
Then
Await
wv.ExecuteScriptAsync("appendAnswer("
& safeContentJson &
")")
End
If
End
Sub
' ---- 4. 发送请求
----
e.Sender.Enabled =
False
Await
caller.GetResultAsync()
'请不要漏掉Await关键词
e.Sender.Enabled =
True