lh 4 месяцев назад
Родитель
Сommit
ea109b0b38

+ 124 - 0
app/Http/Controllers/DeepSeek/DeepSeekController.php

@@ -280,6 +280,130 @@ class DeepSeekController extends BaseController
     }
 
     /**
+     * 测试流式输出(简化版本)
+     */
+    public function testStream(Request $request) {
+        return response()->stream(function () {
+            // 禁用所有输出缓冲
+            if (ob_get_level()) {
+                ob_end_clean();
+            }
+            
+            ini_set('output_buffering', 'off');
+            ini_set('zlib.output_compression', 'off');
+
+            // 发送测试数据
+            for ($i = 1; $i <= 5; $i++) {
+                $data = [
+                    'type' => 'content',
+                    'content' => "测试消息 #{$i}\n",
+                    'full_content' => "累积内容到第 {$i} 条\n"
+                ];
+                
+                echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
+                
+                if (ob_get_level() > 0) {
+                    ob_flush();
+                }
+                flush();
+                
+                sleep(1); // 每秒发送一条
+            }
+            
+            // 发送结束标记
+            echo "data: [DONE]\n\n";
+            if (ob_get_level() > 0) {
+                ob_flush();
+            }
+            flush();
+        }, 200, [
+            'Content-Type' => 'text/event-stream',
+            'Cache-Control' => 'no-cache',
+            'Connection' => 'keep-alive',
+            'X-Accel-Buffering' => 'no'
+        ]);
+    }
+
+    /**
+     * 与DeepSeek对话(带文件上传 - 流式输出版本)
+     * @param Request $request
+     * @return \Symfony\Component\HttpFoundation\StreamedResponse
+     * @throws ApiException
+     */
+    public function chatWithFileStream(Request $request) {
+        // 忽略所有超时限制
+        set_time_limit(0);
+        ini_set('max_execution_time', '0');
+
+        $data = $request->all();
+
+        return response()->stream(function () use ($data) {
+            // 禁用所有输出缓冲
+            if (ob_get_level()) {
+                ob_end_clean();
+            }
+            
+            // 设置输出缓冲为关闭
+            ini_set('output_buffering', 'off');
+            ini_set('zlib.output_compression', 'off');
+            
+            // 立即刷新
+            if (function_exists('apache_setenv')) {
+                apache_setenv('no-gzip', '1');
+            }
+
+            try {
+                $generator = $this->deepseekService->chatWithFileStream($data);
+                
+                foreach ($generator as $chunk) {
+                    // 发送 SSE 格式的数据
+                    $jsonData = json_encode($chunk, JSON_UNESCAPED_UNICODE);
+                    echo "data: {$jsonData}\n\n";
+                    
+                    // 强制刷新输出缓冲区
+                    if (ob_get_level() > 0) {
+                        ob_flush();
+                    }
+                    flush();
+                    
+                    // 检查客户端是否断开连接
+                    if (connection_aborted()) {
+                        break;
+                    }
+                }
+                
+                // 发送结束标记
+                echo "data: [DONE]\n\n";
+                if (ob_get_level() > 0) {
+                    ob_flush();
+                }
+                flush();
+                
+            } catch (\Exception $e) {
+                // 发送错误信息
+                $error = [
+                    'type' => 'error',
+                    'message' => $e->getMessage(),
+                    'code' => $e->getCode()
+                ];
+                $jsonError = json_encode($error, JSON_UNESCAPED_UNICODE);
+                echo "data: {$jsonError}\n\n";
+                if (ob_get_level() > 0) {
+                    ob_flush();
+                }
+                flush();
+            }
+        }, 200, [
+            'Content-Type' => 'text/event-stream',
+            'Cache-Control' => 'no-cache',
+            'Connection' => 'keep-alive',
+            'X-Accel-Buffering' => 'no', // 防止 Nginx 缓冲
+            'Access-Control-Allow-Origin' => '*',
+            'Access-Control-Allow-Credentials' => 'true'
+        ]);
+    }
+
+    /**
      * 保存剧本
      */
     public function saveScript(Request $request) {

Разница между файлами не показана из-за своего большого размера
+ 240 - 0
app/Services/DeepSeek/DeepSeekService.php


+ 766 - 0
public/deepseek-stream-demo.html

@@ -0,0 +1,766 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>DeepSeek 流式输出示例</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            min-height: 100vh;
+            padding: 20px;
+        }
+
+        .container {
+            max-width: 1200px;
+            margin: 0 auto;
+            background: white;
+            border-radius: 16px;
+            box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+            overflow: hidden;
+        }
+
+        .header {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+            padding: 30px;
+            text-align: center;
+        }
+
+        .header h1 {
+            font-size: 28px;
+            margin-bottom: 10px;
+        }
+
+        .header p {
+            opacity: 0.9;
+            font-size: 14px;
+        }
+
+        .content {
+            padding: 30px;
+        }
+
+        .form-group {
+            margin-bottom: 20px;
+        }
+
+        .form-group label {
+            display: block;
+            margin-bottom: 8px;
+            font-weight: 600;
+            color: #333;
+        }
+
+        .form-group input,
+        .form-group textarea,
+        .form-group select {
+            width: 100%;
+            padding: 12px;
+            border: 2px solid #e0e0e0;
+            border-radius: 8px;
+            font-size: 14px;
+            transition: border-color 0.3s;
+        }
+
+        .form-group input:focus,
+        .form-group textarea:focus,
+        .form-group select:focus {
+            outline: none;
+            border-color: #667eea;
+        }
+
+        .form-group textarea {
+            min-height: 120px;
+            resize: vertical;
+            font-family: inherit;
+        }
+
+        .form-row {
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: 20px;
+        }
+
+        .btn {
+            padding: 12px 30px;
+            border: none;
+            border-radius: 8px;
+            font-size: 16px;
+            font-weight: 600;
+            cursor: pointer;
+            transition: all 0.3s;
+        }
+
+        .btn-primary {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            color: white;
+        }
+
+        .btn-primary:hover:not(:disabled) {
+            transform: translateY(-2px);
+            box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
+        }
+
+        .btn-primary:disabled {
+            opacity: 0.6;
+            cursor: not-allowed;
+        }
+
+        .btn-danger {
+            background: #ef4444;
+            color: white;
+            margin-left: 10px;
+        }
+
+        .btn-danger:hover:not(:disabled) {
+            background: #dc2626;
+        }
+
+        .output-section {
+            margin-top: 30px;
+            display: none;
+        }
+
+        .output-section.active {
+            display: block;
+        }
+
+        .output-box {
+            background: #f8f9fa;
+            border: 2px solid #e0e0e0;
+            border-radius: 8px;
+            padding: 20px;
+            margin-bottom: 20px;
+        }
+
+        .output-box h3 {
+            color: #667eea;
+            margin-bottom: 15px;
+            font-size: 18px;
+            display: flex;
+            align-items: center;
+            gap: 10px;
+        }
+
+        .output-box .content-area {
+            background: white;
+            padding: 15px;
+            border-radius: 6px;
+            min-height: 100px;
+            max-height: 400px;
+            overflow-y: auto;
+            white-space: pre-wrap;
+            word-wrap: break-word;
+            line-height: 1.6;
+            font-size: 14px;
+            color: #333;
+        }
+
+        .status-badge {
+            display: inline-block;
+            padding: 4px 12px;
+            border-radius: 12px;
+            font-size: 12px;
+            font-weight: 600;
+        }
+
+        .status-connecting {
+            background: #fef3c7;
+            color: #92400e;
+        }
+
+        .status-streaming {
+            background: #dbeafe;
+            color: #1e40af;
+            animation: pulse 2s infinite;
+        }
+
+        .status-completed {
+            background: #d1fae5;
+            color: #065f46;
+        }
+
+        .status-error {
+            background: #fee2e2;
+            color: #991b1b;
+        }
+
+        @keyframes pulse {
+            0%, 100% { opacity: 1; }
+            50% { opacity: 0.7; }
+        }
+
+        .loading-spinner {
+            display: inline-block;
+            width: 16px;
+            height: 16px;
+            border: 2px solid #667eea;
+            border-top-color: transparent;
+            border-radius: 50%;
+            animation: spin 0.8s linear infinite;
+        }
+
+        @keyframes spin {
+            to { transform: rotate(360deg); }
+        }
+
+        .stats {
+            display: grid;
+            grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+            gap: 15px;
+            margin-top: 20px;
+        }
+
+        .stat-item {
+            background: white;
+            padding: 15px;
+            border-radius: 8px;
+            border: 1px solid #e0e0e0;
+        }
+
+        .stat-item .label {
+            font-size: 12px;
+            color: #666;
+            margin-bottom: 5px;
+        }
+
+        .stat-item .value {
+            font-size: 20px;
+            font-weight: 700;
+            color: #667eea;
+        }
+
+        .script-preview {
+            background: white;
+            padding: 20px;
+            border-radius: 8px;
+            margin-top: 15px;
+        }
+
+        .script-section {
+            margin-bottom: 20px;
+            padding-bottom: 20px;
+            border-bottom: 1px solid #e0e0e0;
+        }
+
+        .script-section:last-child {
+            border-bottom: none;
+        }
+
+        .script-section h4 {
+            color: #667eea;
+            margin-bottom: 10px;
+            font-size: 16px;
+        }
+
+        .script-section p {
+            line-height: 1.8;
+            color: #333;
+        }
+
+        .episode-item {
+            background: #f8f9fa;
+            padding: 15px;
+            border-radius: 6px;
+            margin-bottom: 10px;
+        }
+
+        .episode-title {
+            font-weight: 600;
+            color: #667eea;
+            margin-bottom: 8px;
+        }
+
+        .file-upload-area {
+            border: 2px dashed #e0e0e0;
+            border-radius: 8px;
+            padding: 30px;
+            text-align: center;
+            cursor: pointer;
+            transition: all 0.3s;
+        }
+
+        .file-upload-area:hover {
+            border-color: #667eea;
+            background: #f8f9fa;
+        }
+
+        .file-upload-area.dragover {
+            border-color: #667eea;
+            background: #eff6ff;
+        }
+
+        .file-info {
+            margin-top: 10px;
+            padding: 10px;
+            background: #f0fdf4;
+            border-radius: 6px;
+            color: #166534;
+            font-size: 14px;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="header">
+            <h1>🤖 DeepSeek 流式输出示例</h1>
+            <p>实时查看 AI 生成剧本的过程</p>
+        </div>
+
+        <div class="content">
+            <!-- 输入表单 -->
+            <form id="chatForm">
+                <div class="form-row">
+                    <div class="form-group">
+                        <label>剧本 ID *</label>
+                        <input type="number" id="scriptId" name="script_id" required placeholder="请输入剧本ID">
+                    </div>
+                    <div class="form-group">
+                        <label>模型选择</label>
+                        <select id="model" name="model">
+                            <option value="v3">DeepSeek-V3 (推荐)</option>
+                            <option value="r1">DeepSeek-R1 (思考模式)</option>
+                        </select>
+                    </div>
+                </div>
+
+                <div class="form-row">
+                    <div class="form-group">
+                        <label>开始集数</label>
+                        <input type="number" id="startEpisode" name="start_episode_sequence" value="1" min="1">
+                    </div>
+                    <div class="form-group">
+                        <label>总集数</label>
+                        <input type="number" id="totalEpisode" name="total_episode_num" value="30" min="1">
+                    </div>
+                </div>
+
+                <div class="form-group">
+                    <label>文件上传 (可选)</label>
+                    <div class="file-upload-area" id="fileUploadArea">
+                        <p>📁 点击或拖拽文件到此处</p>
+                        <p style="font-size: 12px; color: #666; margin-top: 5px;">支持 TXT、PDF 格式</p>
+                    </div>
+                    <input type="file" id="fileInput" accept=".txt,.pdf" style="display: none;">
+                    <div id="fileInfo" class="file-info" style="display: none;"></div>
+                </div>
+
+                <div class="form-group">
+                    <label>文本内容 (可选)</label>
+                    <textarea id="content" name="content" placeholder="或者直接输入文本内容..."></textarea>
+                </div>
+
+                <div class="form-group">
+                    <label>自定义问题 (可选)</label>
+                    <textarea id="question" name="question" rows="3" placeholder="留空使用默认问题"></textarea>
+                </div>
+
+                <div style="text-align: center; margin-top: 30px;">
+                    <button type="submit" class="btn btn-primary" id="submitBtn">
+                        🚀 开始生成
+                    </button>
+                    <button type="button" class="btn btn-danger" id="stopBtn" style="display: none;">
+                        ⏹️ 停止生成
+                    </button>
+                </div>
+            </form>
+
+            <!-- 输出区域 -->
+            <div class="output-section" id="outputSection">
+                <div class="output-box">
+                    <h3>
+                        <span>📊 生成状态</span>
+                        <span class="status-badge" id="statusBadge">准备中</span>
+                    </h3>
+                    <div class="stats">
+                        <div class="stat-item">
+                            <div class="label">已接收字符</div>
+                            <div class="value" id="charCount">0</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="label">生成时间</div>
+                            <div class="value" id="elapsedTime">0s</div>
+                        </div>
+                        <div class="stat-item">
+                            <div class="label">Token 使用</div>
+                            <div class="value" id="tokenUsage">-</div>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- 思维链输出 (仅 R1 模型) -->
+                <div class="output-box" id="reasoningBox" style="display: none;">
+                    <h3>
+                        <span class="loading-spinner"></span>
+                        💭 思维链 (Reasoning)
+                    </h3>
+                    <div class="content-area" id="reasoningContent"></div>
+                </div>
+
+                <!-- 内容输出 -->
+                <div class="output-box">
+                    <h3>
+                        <span class="loading-spinner"></span>
+                        ✍️ 生成内容
+                    </h3>
+                    <div class="content-area" id="contentOutput"></div>
+                </div>
+
+                <!-- 最终结果 -->
+                <div class="output-box" id="resultBox" style="display: none;">
+                    <h3>✅ 剧本结构化数据</h3>
+                    <div class="script-preview" id="scriptPreview"></div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        // 全局变量
+        let isStreaming = false;
+        let startTime = null;
+        let timerInterval = null;
+        let charCount = 0;
+        let selectedFile = null;
+        let currentReader = null; // 保存当前的 reader 引用
+        let abortController = null; // 用于中断请求
+
+        // DOM 元素
+        const form = document.getElementById('chatForm');
+        const submitBtn = document.getElementById('submitBtn');
+        const stopBtn = document.getElementById('stopBtn');
+        const outputSection = document.getElementById('outputSection');
+        const statusBadge = document.getElementById('statusBadge');
+        const reasoningBox = document.getElementById('reasoningBox');
+        const reasoningContent = document.getElementById('reasoningContent');
+        const contentOutput = document.getElementById('contentOutput');
+        const resultBox = document.getElementById('resultBox');
+        const scriptPreview = document.getElementById('scriptPreview');
+        const charCountEl = document.getElementById('charCount');
+        const elapsedTimeEl = document.getElementById('elapsedTime');
+        const tokenUsageEl = document.getElementById('tokenUsage');
+        const fileUploadArea = document.getElementById('fileUploadArea');
+        const fileInput = document.getElementById('fileInput');
+        const fileInfo = document.getElementById('fileInfo');
+
+        // 文件上传处理
+        fileUploadArea.addEventListener('click', () => fileInput.click());
+        
+        fileInput.addEventListener('change', (e) => {
+            const file = e.target.files[0];
+            if (file) {
+                selectedFile = file;
+                fileInfo.textContent = `已选择: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`;
+                fileInfo.style.display = 'block';
+            }
+        });
+
+        // 拖拽上传
+        fileUploadArea.addEventListener('dragover', (e) => {
+            e.preventDefault();
+            fileUploadArea.classList.add('dragover');
+        });
+
+        fileUploadArea.addEventListener('dragleave', () => {
+            fileUploadArea.classList.remove('dragover');
+        });
+
+        fileUploadArea.addEventListener('drop', (e) => {
+            e.preventDefault();
+            fileUploadArea.classList.remove('dragover');
+            const file = e.dataTransfer.files[0];
+            if (file && (file.name.endsWith('.txt') || file.name.endsWith('.pdf'))) {
+                selectedFile = file;
+                fileInput.files = e.dataTransfer.files;
+                fileInfo.textContent = `已选择: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`;
+                fileInfo.style.display = 'block';
+            } else {
+                alert('请上传 TXT 或 PDF 格式的文件');
+            }
+        });
+
+        // 表单提交
+        form.addEventListener('submit', async (e) => {
+            e.preventDefault();
+            
+            if (isStreaming) return;
+
+            // 重置状态
+            resetOutput();
+            outputSection.classList.add('active');
+            isStreaming = true;
+            submitBtn.disabled = true;
+            stopBtn.style.display = 'inline-block';
+            startTime = Date.now();
+            startTimer();
+
+            // 更新状态
+            updateStatus('connecting', '连接中...');
+
+            // 准备表单数据
+            const formData = new FormData();
+            formData.append('script_id', document.getElementById('scriptId').value);
+            formData.append('model', document.getElementById('model').value);
+            formData.append('start_episode_sequence', document.getElementById('startEpisode').value);
+            formData.append('total_episode_num', document.getElementById('totalEpisode').value);
+            
+            const content = document.getElementById('content').value;
+            if (content) {
+                formData.append('content', content);
+            }
+            
+            const question = document.getElementById('question').value;
+            if (question) {
+                formData.append('question', question);
+            }
+
+            if (selectedFile) {
+                formData.append('file', selectedFile);
+            }
+
+            // 检查是否为 R1 模型
+            const isR1Model = document.getElementById('model').value === 'r1';
+            if (isR1Model) {
+                reasoningBox.style.display = 'block';
+            }
+
+            // 创建 AbortController 用于中断请求
+            abortController = new AbortController();
+
+            try {
+                // 发起流式请求,传入 signal 用于中断
+                const response = await fetch('/api/chatWithFileStream', {
+                    method: 'POST',
+                    body: formData,
+                    signal: abortController.signal // 关键:添加中断信号
+                });
+
+                if (!response.ok) {
+                    throw new Error(`HTTP error! status: ${response.status}`);
+                }
+
+                updateStatus('streaming', '生成中...');
+
+                // 读取流式响应
+                currentReader = response.body.getReader();
+                const decoder = new TextDecoder();
+                let buffer = '';
+
+                console.log('开始读取流式数据...'); // 调试日志
+
+                while (true) {
+                    const { done, value } = await currentReader.read();
+                    
+                    if (done) {
+                        console.log('流式读取完成'); // 调试日志
+                        break;
+                    }
+
+                    buffer += decoder.decode(value, { stream: true });
+                    const lines = buffer.split('\n');
+                    buffer = lines.pop(); // 保留不完整的行
+
+                    for (const line of lines) {
+                        if (line.startsWith('data: ')) {
+                            const data = line.slice(6);
+                            
+                            console.log('收到原始数据:', data); // 调试日志
+                            
+                            if (data === '[DONE]') {
+                                console.log('收到结束标记'); // 调试日志
+                                handleStreamComplete();
+                                return;
+                            }
+
+                            try {
+                                const json = JSON.parse(data);
+                                handleStreamData(json);
+                            } catch (e) {
+                                console.error('解析 JSON 失败:', e, '原始数据:', data);
+                            }
+                        }
+                    }
+                }
+
+            } catch (error) {
+                if (error.name === 'AbortError') {
+                    console.log('请求已被用户中断');
+                    updateStatus('error', '已停止');
+                } else {
+                    console.error('流式请求错误:', error);
+                    updateStatus('error', '生成失败');
+                    alert('生成失败: ' + error.message);
+                }
+            } finally {
+                stopStreaming();
+            }
+        });
+
+        // 停止按钮
+        stopBtn.addEventListener('click', () => {
+            // 中断请求
+            if (abortController) {
+                abortController.abort();
+            }
+            // 取消 reader
+            if (currentReader) {
+                currentReader.cancel();
+            }
+            stopStreaming();
+            updateStatus('error', '已停止');
+        });
+
+        // 处理流式数据
+        function handleStreamData(data) {
+            console.log('收到数据:', data); // 调试日志
+            
+            if (data.type === 'reasoning') {
+                // 思维链内容
+                // 注释掉下面两行则不展示思维链内容,仅计数
+                reasoningContent.textContent = data.full_reasoning || '';
+                reasoningContent.scrollTop = reasoningContent.scrollHeight;
+                charCount += (data.content || '').length;
+                updateCharCount();
+            } else if (data.type === 'content') {
+                // 最终回答内容
+                contentOutput.textContent = data.full_content || '';
+                contentOutput.scrollTop = contentOutput.scrollHeight;
+                charCount += (data.content || '').length;
+                updateCharCount();
+            } else if (data.type === 'done') {
+                // 完成
+                handleComplete(data);
+            } else if (data.type === 'error') {
+                // 错误
+                updateStatus('error', '生成失败');
+                alert('错误: ' + data.message);
+            }
+        }
+
+        // 处理完成
+        function handleComplete(data) {
+            updateStatus('completed', '生成完成');
+            
+            // 显示 Token 使用情况
+            if (data.usage) {
+                const total = data.usage.total_tokens || 0;
+                tokenUsageEl.textContent = total.toLocaleString();
+            }
+
+            // 显示结构化剧本数据
+            if (data.script) {
+                displayScript(data.script);
+                resultBox.style.display = 'block';
+            }
+        }
+
+        // 显示剧本结构
+        function displayScript(script) {
+            let html = '';
+
+            if (script.intro) {
+                html += `<div class="script-section">
+                    <h4>📖 故事梗概</h4>
+                    <p>${escapeHtml(script.intro)}</p>
+                </div>`;
+            }
+
+            if (script.highlights) {
+                html += `<div class="script-section">
+                    <h4>✨ 剧本亮点</h4>
+                    <p>${escapeHtml(script.highlights)}</p>
+                </div>`;
+            }
+
+            if (script.role_relationship) {
+                html += `<div class="script-section">
+                    <h4>👥 人物关系</h4>
+                    <p>${escapeHtml(script.role_relationship)}</p>
+                </div>`;
+            }
+
+            if (script.episodes && script.episodes.length > 0) {
+                html += `<div class="script-section">
+                    <h4>🎬 分集剧本 (共 ${script.episodes.length} 集)</h4>`;
+                script.episodes.forEach(ep => {
+                    html += `<div class="episode-item">
+                        <div class="episode-title">${escapeHtml(ep.title || ep.episode_number)}</div>
+                        <p>${escapeHtml(ep.content || '').substring(0, 200)}...</p>
+                    </div>`;
+                });
+                html += `</div>`;
+            }
+
+            scriptPreview.innerHTML = html || '<p style="color: #666;">暂无结构化数据</p>';
+        }
+
+        // 流式完成
+        function handleStreamComplete() {
+            stopStreaming();
+            updateStatus('completed', '生成完成');
+        }
+
+        // 停止流式传输
+        function stopStreaming() {
+            isStreaming = false;
+            submitBtn.disabled = false;
+            stopBtn.style.display = 'none';
+            stopTimer();
+        }
+
+        // 重置输出
+        function resetOutput() {
+            reasoningContent.textContent = '';
+            contentOutput.textContent = '';
+            scriptPreview.innerHTML = '';
+            resultBox.style.display = 'none';
+            reasoningBox.style.display = 'none';
+            charCount = 0;
+            updateCharCount();
+            tokenUsageEl.textContent = '-';
+        }
+
+        // 更新状态
+        function updateStatus(type, text) {
+            statusBadge.className = 'status-badge status-' + type;
+            statusBadge.textContent = text;
+        }
+
+        // 更新字符计数
+        function updateCharCount() {
+            charCountEl.textContent = charCount.toLocaleString();
+        }
+
+        // 开始计时器
+        function startTimer() {
+            timerInterval = setInterval(() => {
+                const elapsed = Math.floor((Date.now() - startTime) / 1000);
+                elapsedTimeEl.textContent = elapsed + 's';
+            }, 1000);
+        }
+
+        // 停止计时器
+        function stopTimer() {
+            if (timerInterval) {
+                clearInterval(timerInterval);
+                timerInterval = null;
+            }
+        }
+
+        // HTML 转义
+        function escapeHtml(text) {
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML;
+        }
+    </script>
+</body>
+</html>

+ 1 - 0
routes/api.php

@@ -107,6 +107,7 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
     });
     
 });
+Route::post('chatWithFileStream', [DeepSeekController::class, 'chatWithFileStream']);
 Route::get('login', [AccountController::class, 'login']); // 登录
 Route::get('logout', [AccountController::class, 'logout']); // 退出
 Route::get('sseLink', [DeepSeekController::class, 'sseLink']); // sseLink