可上传图片分析的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


本页地址:http://www.foxtable.com/webhelp/topics/7226.htm