图片上传识别功能
This commit is contained in:
80
(红头)服务器启动的注意事项.md
Normal file
80
(红头)服务器启动的注意事项.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 启动注意事项
|
||||
|
||||
本文面向在 **Windows** 上本地运行本仓库(后端 API、Celery、前端、Redis)时的常见配置与排错要点。
|
||||
|
||||
---
|
||||
|
||||
## 1. Redis 端口与 `.env` 必须一致
|
||||
|
||||
- `backend/.env` 中的 **`REDIS_URL`**(例如 `redis://localhost:6380/0`)必须与**实际监听的 Redis 端口**一致。
|
||||
- 仓库内 `start_aiagent.ps1` 默认启动的是 **`6379`**。若 `.env` 写的是 **6380**,请要么:
|
||||
- 在本机启动监听 **6380** 的 Redis,要么
|
||||
- 把 `.env` 改为 **6379** 并使用脚本启动的 Redis。
|
||||
- **症状**:创建执行返回 **503**、日志中出现无法连接 Redis / Celery 入队失败。
|
||||
|
||||
---
|
||||
|
||||
## 2. API 与 Celery 必须使用同一虚拟环境
|
||||
|
||||
- 工作流/Agent 执行由 **Celery Worker** 跑,与 **uvicorn API** 应共用 **`backend\venv`**。
|
||||
- 更新依赖后请在 **`backend` 目录**执行:
|
||||
|
||||
```powershell
|
||||
.\venv\Scripts\pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- **改完 `.env` 或 Python 依赖后**,需要**重启 API 和 Celery**,否则仍加载旧环境。
|
||||
- 仓库提供**仅重启后端 + Celery**(不停止前端/本机 Redis)的脚本:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File D:\aaa\aiagent\restart_backend_celery.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 图片 OCR(作业/聊天里识别图中文字)
|
||||
|
||||
- `file_read` 读图片依赖:**Pillow**、**pytesseract**,以及本机安装 **Tesseract 可执行文件**。
|
||||
- 在 `backend/.env` 中建议配置(路径按本机修改):
|
||||
- **`TESSERACT_CMD`**:指向 `tesseract.exe`(例如 `C:/Program Files/Tesseract-OCR/tesseract.exe`)。
|
||||
- **`TESSERACT_TESSDATA_DIR`**(可选):指向含 **`chi_sim.traineddata`** 的目录,中文识别更稳定。
|
||||
- 自检:
|
||||
|
||||
```powershell
|
||||
cd D:\aaa\aiagent\backend
|
||||
.\venv\Scripts\python scripts\check_ocr_env.py
|
||||
```
|
||||
|
||||
- **症状**:助手回复里出现「请安装 Pillow / pytesseract」或无法识别图中文字 → 先检查 venv 是否已 `pip install`,再检查 Tesseract 与 `.env`,最后**重启 Celery**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端与后端地址
|
||||
|
||||
- 前端开发服通过代理访问 API;一键脚本里会通过环境变量指向当前 API 地址。
|
||||
- 若 OpenAPI 里**没有**新加的路由(例如上传),多半是 **API 进程仍是旧代码/旧进程**,需要重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 5. 鉴权与安全
|
||||
|
||||
- 上传、执行等接口需要 **JWT**;预览对话若出现 **401**,请重新登录后再试。
|
||||
- **勿**在文档或 Git 中提交 **明文密码、密钥、完整 `.env`**;`.env` 应留在本机并加入版本忽略。
|
||||
|
||||
---
|
||||
|
||||
## 6. 一键启停脚本(参考)
|
||||
|
||||
| 脚本 | 作用 |
|
||||
|------|------|
|
||||
| `start_aiagent.ps1` | 启动 Redis(6379)、API、Celery、前端(按脚本内端口逻辑) |
|
||||
| `stop_aiagent.ps1` | 停止 API、Celery、前端、以及匹配的 redis-server 进程 |
|
||||
| `restart_backend_celery.ps1` | 仅重启 API(8037) + Celery,适合改依赖或 `.env` 后快速生效 |
|
||||
|
||||
实际端口以你本机 **`start_aiagent.ps1` / `restart_backend_celery.ps1`** 及 `.env` 为准。
|
||||
|
||||
---
|
||||
|
||||
## 7. 修改记录建议
|
||||
|
||||
- 变更依赖或环境变量后:在本机记一句「已重启 API + Celery」便于以后排查。
|
||||
@@ -76,6 +76,7 @@ class PreviewChatTurnResponse(BaseModel):
|
||||
created_at: datetime
|
||||
user_text: str
|
||||
agent_text: str
|
||||
attachments: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -6,15 +6,17 @@ from __future__ import annotations
|
||||
import re
|
||||
import uuid
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.services.builtin_tools import _local_file_workspace_root
|
||||
from app.services.builtin_tools import _local_file_workspace_root, _resolve_path_under_workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,3 +112,32 @@ async def upload_preview_file(
|
||||
size=total,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preview/file")
|
||||
async def get_preview_file(
|
||||
file_path: str = Query(..., description="上传后返回的 relative_path"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
读取当前用户预览附件(用于前端历史回显图片缩略图)。
|
||||
仅允许访问 uploads/preview/<current_user.id>/ 下文件。
|
||||
"""
|
||||
path, err = _resolve_path_under_workspace(file_path)
|
||||
if err or path is None:
|
||||
raise HTTPException(status_code=400, detail=f"无效文件路径: {err or file_path}")
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
root = _local_file_workspace_root()
|
||||
try:
|
||||
rel = path.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="不允许访问该文件")
|
||||
|
||||
prefix = f"uploads/preview/{current_user.id}/"
|
||||
if not rel.startswith(prefix):
|
||||
raise HTTPException(status_code=403, detail="无权访问该文件")
|
||||
|
||||
media_type, _ = mimetypes.guess_type(str(path))
|
||||
return FileResponse(path=str(path), media_type=media_type or "application/octet-stream")
|
||||
|
||||
@@ -97,6 +97,31 @@ def _user_id_from_input(input_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_attachments(input_data: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
if not input_data:
|
||||
return []
|
||||
raw = input_data.get("attachments")
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
rel = str(item.get("relative_path") or "").strip()
|
||||
name = str(item.get("filename") or "").strip()
|
||||
if not rel:
|
||||
continue
|
||||
content_type = str(item.get("content_type") or "").strip() or None
|
||||
out.append(
|
||||
{
|
||||
"relative_path": rel,
|
||||
"filename": name or rel.rsplit("/", 1)[-1],
|
||||
"content_type": content_type,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_agent_preview_chat_turns(
|
||||
db: Session,
|
||||
agent_id: str,
|
||||
@@ -149,6 +174,7 @@ def fetch_agent_preview_chat_turns(
|
||||
"created_at": ex.created_at,
|
||||
"user_text": user_text or "(无文本)",
|
||||
"agent_text": agent_text or "(无输出)",
|
||||
"attachments": _extract_attachments(inp),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
58
backend/scripts/check_ocr_env.py
Normal file
58
backend/scripts/check_ocr_env.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""检查图片 OCR 环境:Pillow、pytesseract、Tesseract 可执行文件、chi_sim 语言包。
|
||||
|
||||
在 backend 目录执行:
|
||||
.\\venv\\Scripts\\python scripts\\check_ocr_env.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 保证能加载 app 配置
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
from app.services.builtin_tools import _local_file_workspace_root, _tessdata_dir_for_ocr # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("TESSERACT_CMD (settings):", settings.TESSERACT_CMD or "(空,将尝试 PATH)")
|
||||
print("TESSERACT_TESSDATA_DIR (settings):", settings.TESSERACT_TESSDATA_DIR or "(空,将尝试仓库 tessdata/)")
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
|
||||
print("Pillow: OK")
|
||||
except ImportError as e:
|
||||
print("Pillow: 缺失 —", e)
|
||||
print(" 请执行: pip install Pillow")
|
||||
return 2
|
||||
try:
|
||||
import pytesseract as pt
|
||||
|
||||
print("pytesseract: OK")
|
||||
except ImportError as e:
|
||||
print("pytesseract: 缺失 —", e)
|
||||
print(" 请执行: pip install pytesseract")
|
||||
return 3
|
||||
cmd = (settings.TESSERACT_CMD or "").strip()
|
||||
if cmd:
|
||||
pt.pytesseract.tesseract_cmd = cmd
|
||||
try:
|
||||
ver = pt.get_tesseract_version()
|
||||
print("Tesseract 版本:", ver)
|
||||
except Exception as e:
|
||||
print("Tesseract 可执行文件: 不可用 —", e)
|
||||
print(" Windows 请安装 Tesseract,并在 .env 设置 TESSERACT_CMD=.../tesseract.exe")
|
||||
return 4
|
||||
td = _tessdata_dir_for_ocr()
|
||||
print("解析到的 tessdata 目录:", td or "(未找到)")
|
||||
root = _local_file_workspace_root()
|
||||
loc = root / "tessdata"
|
||||
if loc.is_dir():
|
||||
has_chi = any(loc.glob("chi_sim.traineddata"))
|
||||
print("仓库 tessdata/chi_sim.traineddata:", "存在" if has_chi else "缺失(中文识别差)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -48,6 +48,28 @@
|
||||
/>
|
||||
<div class="message-content">
|
||||
<div class="message-bubble" v-html="formatMessage(message.content)"></div>
|
||||
<div
|
||||
v-if="message.attachments?.length"
|
||||
:class="['message-attachments', message.role === 'user' ? 'is-user' : 'is-agent']"
|
||||
>
|
||||
<div
|
||||
v-for="(a, ai) in message.attachments"
|
||||
:key="`msg-att-${index}-${ai}`"
|
||||
class="message-attachment-item"
|
||||
>
|
||||
<img
|
||||
v-if="a.isImage && a.thumbUrl"
|
||||
:src="a.thumbUrl"
|
||||
:alt="a.filename"
|
||||
class="message-attachment-image"
|
||||
@click="openImagePreview(a)"
|
||||
/>
|
||||
<div v-else class="message-attachment-file">
|
||||
<el-icon :size="14"><Document /></el-icon>
|
||||
<span class="message-attachment-name">{{ a.filename }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
|
||||
</div>
|
||||
<el-avatar
|
||||
@@ -104,8 +126,31 @@
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<el-dialog
|
||||
v-model="imagePreviewVisible"
|
||||
title="图片预览"
|
||||
width="min(86vw, 980px)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="image-preview-dialog-body">
|
||||
<img
|
||||
v-if="imagePreviewSrc"
|
||||
:src="imagePreviewSrc"
|
||||
:alt="imagePreviewTitle || '图片预览'"
|
||||
class="image-preview-dialog-img"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<div
|
||||
class="chat-input-area"
|
||||
:class="{ 'is-drag-over': inputDragOver }"
|
||||
@dragenter.prevent="onInputDragEnter"
|
||||
@dragleave.prevent="onInputDragLeave"
|
||||
@dragover.prevent="onInputDragOver"
|
||||
@drop.prevent="onInputDrop"
|
||||
>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
@@ -160,7 +205,7 @@
|
||||
v-model="inputMessage"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="发送消息..."
|
||||
placeholder="发送消息…(可将图片拖入此区域)"
|
||||
@keydown.enter.exact.prevent="handleSendMessage"
|
||||
@keydown.enter.shift.exact="handleNewLine"
|
||||
:disabled="loading || !agentId"
|
||||
@@ -206,12 +251,14 @@ interface Message {
|
||||
role: 'user' | 'agent'
|
||||
content: string
|
||||
timestamp: number
|
||||
attachments?: MessageAttachment[]
|
||||
}
|
||||
|
||||
interface PendingAttachment {
|
||||
relative_path: string
|
||||
filename: string
|
||||
size?: number
|
||||
content_type?: string
|
||||
/** 图片:本地 blob URL,用于缩略图(移除或发送后 revoke) */
|
||||
thumbUrl?: string
|
||||
/** 浏览器本地读取的纯文本预览(发送后也会写入气泡) */
|
||||
@@ -220,6 +267,14 @@ interface PendingAttachment {
|
||||
previewNote?: string
|
||||
}
|
||||
|
||||
interface MessageAttachment {
|
||||
relative_path: string
|
||||
filename: string
|
||||
thumbUrl?: string
|
||||
isImage?: boolean
|
||||
content_type?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
agentId?: string
|
||||
agentName?: string
|
||||
@@ -244,6 +299,11 @@ const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const pendingAttachments = ref<PendingAttachment[]>([])
|
||||
/** 已发送、待轮询结束后再 revoke 的附件(含 thumbUrl) */
|
||||
const blobsPendingRevokeAfterRun = ref<PendingAttachment[] | null>(null)
|
||||
/** 拖放图片到输入区时高亮 */
|
||||
const inputDragOver = ref(false)
|
||||
const imagePreviewVisible = ref(false)
|
||||
const imagePreviewSrc = ref('')
|
||||
const imagePreviewTitle = ref('')
|
||||
let pollingInterval: any = null
|
||||
let replyAdded = false // 标志位:防止重复添加回复
|
||||
|
||||
@@ -275,7 +335,17 @@ function readDesignChatFromStorage(agentId: string): Message[] {
|
||||
.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp
|
||||
timestamp: m.timestamp,
|
||||
attachments: Array.isArray(m.attachments)
|
||||
? m.attachments
|
||||
.filter((a: any) => a && typeof a.relative_path === 'string' && typeof a.filename === 'string')
|
||||
.map((a: any) => ({
|
||||
relative_path: String(a.relative_path),
|
||||
filename: String(a.filename),
|
||||
isImage: !!a.isImage,
|
||||
content_type: a.content_type ? String(a.content_type) : undefined
|
||||
}))
|
||||
: undefined
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
@@ -285,7 +355,18 @@ function readDesignChatFromStorage(agentId: string): Message[] {
|
||||
function writeDesignChatToStorage(agentId: string, list: Message[]) {
|
||||
try {
|
||||
const slice = list.slice(-MAX_PERSIST_MESSAGES)
|
||||
let raw = JSON.stringify(slice)
|
||||
const compact = slice.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
attachments: m.attachments?.map((a) => ({
|
||||
relative_path: a.relative_path,
|
||||
filename: a.filename,
|
||||
isImage: !!a.isImage,
|
||||
content_type: a.content_type
|
||||
}))
|
||||
}))
|
||||
let raw = JSON.stringify(compact)
|
||||
if (raw.length > 450_000) {
|
||||
raw = JSON.stringify(slice.slice(-80))
|
||||
}
|
||||
@@ -363,6 +444,7 @@ async function loadChatHistory() {
|
||||
const cached = readDesignChatFromStorage(aid)
|
||||
if (cached.length > 0) {
|
||||
messages.value = cached
|
||||
await hydrateMessageAttachmentThumbs(messages.value)
|
||||
nextTick(() => scrollToBottom())
|
||||
}
|
||||
|
||||
@@ -372,15 +454,29 @@ async function loadChatHistory() {
|
||||
agent_text: string
|
||||
created_at: string
|
||||
execution_id: string
|
||||
attachments?: Array<{
|
||||
relative_path: string
|
||||
filename: string
|
||||
content_type?: string
|
||||
}>
|
||||
}>
|
||||
) => {
|
||||
const next: Message[] = []
|
||||
for (const t of turns) {
|
||||
const base = new Date(t.created_at).getTime()
|
||||
const msgAttachments: MessageAttachment[] = (t.attachments || []).map((a) => ({
|
||||
relative_path: a.relative_path,
|
||||
filename: a.filename,
|
||||
content_type: a.content_type,
|
||||
isImage:
|
||||
!!a.content_type?.startsWith('image/') ||
|
||||
/\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename || a.relative_path)
|
||||
}))
|
||||
next.push({
|
||||
role: 'user',
|
||||
content: t.user_text || ' ',
|
||||
timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400
|
||||
timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400,
|
||||
attachments: msgAttachments.length ? msgAttachments : undefined
|
||||
})
|
||||
next.push({
|
||||
role: 'agent',
|
||||
@@ -396,6 +492,11 @@ async function loadChatHistory() {
|
||||
agent_text: string
|
||||
created_at: string
|
||||
execution_id: string
|
||||
attachments?: Array<{
|
||||
relative_path: string
|
||||
filename: string
|
||||
content_type?: string
|
||||
}>
|
||||
}
|
||||
|
||||
const fetchHist = async (previewUserId: string | undefined): Promise<HistTurn[]> => {
|
||||
@@ -439,7 +540,9 @@ async function loadChatHistory() {
|
||||
|
||||
if (Array.isArray(turns) && turns.length > 0) {
|
||||
const next = applyServerTurns(turns)
|
||||
revokeMessageAttachmentBlobs(messages.value)
|
||||
messages.value = next
|
||||
await hydrateMessageAttachmentThumbs(messages.value)
|
||||
writeDesignChatToStorage(aid, next)
|
||||
}
|
||||
// 服务端无记录时保留上面已展示的本地镜像(若有)
|
||||
@@ -476,6 +579,55 @@ function revokeAttachmentBlobs(items: PendingAttachment[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function revokeMessageAttachmentBlobs(items: Message[]) {
|
||||
for (const m of items) {
|
||||
for (const a of m.attachments || []) {
|
||||
if (a.thumbUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(a.thumbUrl)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isThumbInMessages(url: string): boolean {
|
||||
if (!url) return false
|
||||
return messages.value.some((m) => (m.attachments || []).some((a) => a.thumbUrl === url))
|
||||
}
|
||||
|
||||
async function fetchAttachmentThumb(relativePath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await api.get('/api/v1/uploads/preview/file', {
|
||||
params: { file_path: relativePath },
|
||||
responseType: 'blob',
|
||||
skipErrorHandler: true
|
||||
})
|
||||
return URL.createObjectURL(res.data as Blob)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateMessageAttachmentThumbs(target: Message[]) {
|
||||
for (const m of target) {
|
||||
for (const a of m.attachments || []) {
|
||||
if (!a.isImage || a.thumbUrl || !a.relative_path) continue
|
||||
const url = await fetchAttachmentThumb(a.relative_path)
|
||||
if (url) a.thumbUrl = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openImagePreview(a: MessageAttachment) {
|
||||
if (!a.thumbUrl) return
|
||||
imagePreviewSrc.value = a.thumbUrl
|
||||
imagePreviewTitle.value = a.filename
|
||||
imagePreviewVisible.value = true
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
const cur = pendingAttachments.value[index]
|
||||
if (cur?.thumbUrl) {
|
||||
@@ -552,18 +704,15 @@ function readLocalTextPreview(file: File): Promise<string | null> {
|
||||
})
|
||||
}
|
||||
|
||||
async function onFileInputChange(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const files = input.files
|
||||
input.value = ''
|
||||
if (!files?.length) return
|
||||
/** 与回形针选择、拖放共用:上传到预览区并加入待发送列表 */
|
||||
async function uploadPreviewFiles(fileList: File[]) {
|
||||
if (!fileList.length) return
|
||||
if (!props.agentId) {
|
||||
ElMessage.warning('请先选中要预览的智能体后再上传附件')
|
||||
return
|
||||
}
|
||||
const uploadedNames: string[] = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
for (const file of fileList) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
@@ -582,6 +731,7 @@ async function onFileInputChange(ev: Event) {
|
||||
relative_path: d.relative_path,
|
||||
filename: d.filename || file.name,
|
||||
size: d.size,
|
||||
content_type: (res.data as any)?.content_type,
|
||||
thumbUrl,
|
||||
previewText,
|
||||
previewNote: previewText
|
||||
@@ -614,6 +764,56 @@ async function onFileInputChange(ev: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileInputChange(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const files = input.files
|
||||
input.value = ''
|
||||
if (!files?.length) return
|
||||
await uploadPreviewFiles(Array.from(files))
|
||||
}
|
||||
|
||||
function onInputDragEnter(e: DragEvent) {
|
||||
if (!props.agentId || loading.value) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
inputDragOver.value = true
|
||||
}
|
||||
|
||||
function onInputDragLeave(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rel = e.relatedTarget as Node | null
|
||||
if (rel && el.contains(rel)) return
|
||||
inputDragOver.value = false
|
||||
}
|
||||
|
||||
function onInputDragOver(e: DragEvent) {
|
||||
if (!props.agentId || loading.value) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
async function onInputDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
inputDragOver.value = false
|
||||
if (!props.agentId || loading.value) {
|
||||
if (!props.agentId) ElMessage.warning('请先选中要预览的智能体后再拖入图片')
|
||||
return
|
||||
}
|
||||
const dt = e.dataTransfer
|
||||
if (!dt?.files?.length) return
|
||||
const list = Array.from(dt.files)
|
||||
const images = list.filter((f) => isImageFile(f))
|
||||
if (images.length === 0) {
|
||||
ElMessage.warning('请拖入图片文件(如 png、jpg、jpeg、webp、gif 等)')
|
||||
return
|
||||
}
|
||||
if (images.length < list.length) {
|
||||
ElMessage.info('已忽略非图片文件,仅添加图片附件')
|
||||
}
|
||||
await uploadPreviewFiles(images)
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = async () => {
|
||||
if ((!inputMessage.value.trim() && pendingAttachments.value.length === 0) || loading.value || !props.agentId)
|
||||
@@ -647,10 +847,21 @@ const handleSendMessage = async () => {
|
||||
}
|
||||
|
||||
// 添加用户消息
|
||||
const userAttachments: MessageAttachment[] = attachSnap.map((a) => ({
|
||||
relative_path: a.relative_path,
|
||||
filename: a.filename,
|
||||
thumbUrl: a.thumbUrl,
|
||||
content_type: a.content_type,
|
||||
isImage:
|
||||
!!a.thumbUrl ||
|
||||
Boolean(a.content_type?.startsWith('image/')) ||
|
||||
/\.(png|jpe?g|gif|webp|bmp|tiff?)$/i.test(a.filename)
|
||||
}))
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: userBubble || '(附件)',
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
attachments: userAttachments.length ? userAttachments : undefined
|
||||
})
|
||||
|
||||
// 滚动到底部
|
||||
@@ -679,7 +890,10 @@ const handleSendMessage = async () => {
|
||||
const checkStatus = async () => {
|
||||
const finishBlobs = () => {
|
||||
if (blobsPendingRevokeAfterRun.value) {
|
||||
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
|
||||
const revokable = blobsPendingRevokeAfterRun.value.filter(
|
||||
(x) => !x.thumbUrl || !isThumbInMessages(x.thumbUrl)
|
||||
)
|
||||
revokeAttachmentBlobs(revokable)
|
||||
blobsPendingRevokeAfterRun.value = null
|
||||
}
|
||||
}
|
||||
@@ -849,6 +1063,7 @@ const handlePresetQuestion = (question: string) => {
|
||||
// 清空对话
|
||||
const handleClearChat = () => {
|
||||
revokeAttachmentBlobs([...pendingAttachments.value])
|
||||
revokeMessageAttachmentBlobs(messages.value)
|
||||
pendingAttachments.value = []
|
||||
if (blobsPendingRevokeAfterRun.value) {
|
||||
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
|
||||
@@ -996,6 +1211,7 @@ onUnmounted(() => {
|
||||
pollingInterval = null
|
||||
}
|
||||
revokeAttachmentBlobs([...pendingAttachments.value])
|
||||
revokeMessageAttachmentBlobs(messages.value)
|
||||
if (blobsPendingRevokeAfterRun.value) {
|
||||
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
|
||||
blobsPendingRevokeAfterRun.value = null
|
||||
@@ -1096,6 +1312,76 @@ onUnmounted(() => {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.message-attachments {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
max-width: min(70%, 420px);
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.message-attachments.is-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-attachments.is-agent {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-attachment-item {
|
||||
max-width: 220px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-attachment-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #fff;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.message-attachment-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.message-attachment-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-preview-dialog-body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.image-preview-dialog-img {
|
||||
max-width: 100%;
|
||||
max-height: 72vh;
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preset-questions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1167,6 +1453,13 @@ onUnmounted(() => {
|
||||
background: white;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
padding: 12px;
|
||||
border-radius: 0;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-input-area.is-drag-over {
|
||||
background: #ecf5ff;
|
||||
box-shadow: inset 0 0 0 2px #409eff;
|
||||
}
|
||||
|
||||
.input-toolbar {
|
||||
|
||||
56
restart_backend_celery.ps1
Normal file
56
restart_backend_celery.ps1
Normal file
@@ -0,0 +1,56 @@
|
||||
# Restart backend API (uvicorn) and Celery worker only; does not stop frontend/Redis.$ErrorActionPreference = "Continue"
|
||||
$RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Backend = Join-Path $RepoRoot "backend"
|
||||
$ApiPort = 8037
|
||||
|
||||
Write-Host "== Stop API + Celery only ==" -ForegroundColor Cyan
|
||||
|
||||
function Stop-ByCommandLine([string]$pattern, [string]$name) {
|
||||
$targets = Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.CommandLine -and $_.CommandLine -match $pattern
|
||||
}
|
||||
if (-not $targets) {
|
||||
Write-Host "[SKIP] ${name}: no matching process" -ForegroundColor DarkGray
|
||||
return
|
||||
}
|
||||
foreach ($p in $targets) {
|
||||
try {
|
||||
Stop-Process -Id $p.ProcessId -Force
|
||||
Write-Host "[OK] stopped ${name} PID=$($p.ProcessId)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[WARN] failed to stop ${name} PID=$($p.ProcessId)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Stop-ByCommandLine "uvicorn\s+app\.main:app" "backend-api"
|
||||
Stop-ByCommandLine "celery\s+-A\s+app\.core\.celery_app\s+worker" "celery-worker"
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
Write-Host "== Start API on $ApiPort + Celery ==" -ForegroundColor Cyan
|
||||
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-Command",
|
||||
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $ApiPort"
|
||||
)
|
||||
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-Command",
|
||||
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8"
|
||||
)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[DONE] 已在新窗口启动 API 与 Celery (请查看弹出的 PowerShell 窗口日志)" -ForegroundColor Green
|
||||
Write-Host "API: http://127.0.0.1:$ApiPort/docs" -ForegroundColor Cyan
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
Write-Host ""
|
||||
Write-Host "Port $ApiPort :" -ForegroundColor Cyan
|
||||
netstat -ano | findstr ":$ApiPort"
|
||||
38
三字经里的人之初.md
Normal file
38
三字经里的人之初.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 《三字经》选段:从"人之初"到"不知义"
|
||||
## 原文内容
|
||||
人之初,性本善。
|
||||
性相近,习相远。
|
||||
苟不教,性乃迁。
|
||||
教之道,贵以专。
|
||||
昔孟母,择邻处。
|
||||
子不学,断机杼。
|
||||
窦燕山,有义方。
|
||||
教五子,名俱扬。
|
||||
养不教,父之过。
|
||||
教不严,师之惰。
|
||||
子不学,非所宜。
|
||||
幼不学,老何为。
|
||||
玉不琢,不成器。
|
||||
人不学,不知义。
|
||||
## 白话译文
|
||||
人刚出生的时候,本性都是善良的。
|
||||
天性虽然相近,但后天的习惯却相差很远。
|
||||
如果不加以教育,善良的本性就会改变。
|
||||
教育的方法,贵在专心致志。
|
||||
从前孟子的母亲,为了选择好的邻居而三次搬家。
|
||||
孟子不学习,她就割断织布机上的布来教育他。
|
||||
窦燕山有好的教育方法。
|
||||
他教育的五个儿子,都很有成就,名声远扬。
|
||||
生养孩子却不教育,是父亲的过错。
|
||||
教育学生却不严格,是老师的懒惰。
|
||||
孩子不学习,是不应该的。
|
||||
小时候不学习,老了能做什么呢?
|
||||
玉石不经过雕琢,就不能成为精美的器物。
|
||||
人不学习,就不懂得道义。
|
||||
## 注释
|
||||
1. **人之初,性本善**:儒家思想认为人性本善,这是孟子"性善论"的观点。
|
||||
2. **性相近,习相远**:出自《论语·阳货》,孔子说:"性相近也,习相远也。"
|
||||
3. **苟不教,性乃迁**:如果不教育,善良的本性就会改变。
|
||||
4. **教之道,贵以专**:教育的方法,最重要的是专心致志。
|
||||
5. **昔孟母,择邻处**:孟母三迁的故事,强调环境对教育的重要性。
|
||||
6. **子不学,断机杼**:孟母断织
|
||||
46
满江红.md
Normal file
46
满江红.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: 满江红·写怀
|
||||
author: 岳飞
|
||||
date: 2024-12-01
|
||||
---
|
||||
|
||||
# 满江红·写怀
|
||||
|
||||
**怒发冲冠,凭栏处、潇潇雨歇。**
|
||||
**抬望眼,仰天长啸,壮怀激烈。**
|
||||
**三十功名尘与土,八千里路云和月。**
|
||||
**莫等闲、白了少年头,空悲切!**
|
||||
|
||||
**靖康耻,犹未雪。**
|
||||
**臣子恨,何时灭!**
|
||||
**驾长车,踏破贺兰山缺。**
|
||||
**壮志饥餐胡虏肉,笑谈渴饮匈奴血。**
|
||||
**待从头、收拾旧山河,朝天阙。**
|
||||
|
||||
---
|
||||
|
||||
## 创作背景
|
||||
|
||||
《满江红·写怀》是南宋抗金名将岳飞创作的一首词。此词上片抒写作者对中原重陷敌手的悲愤,对局势前功尽弃的痛惜,表达了自己继续努力争取壮年立功的心愿;下片抒写作者对民族敌人的深仇大恨,对祖国统一的殷切愿望,对国家朝廷的赤胆忠诚。
|
||||
|
||||
## 词牌解析
|
||||
|
||||
- **词牌名**:满江红
|
||||
- **字数**:93字
|
||||
- **韵脚**:仄韵格,气势磅礴
|
||||
- **创作时间**:约公元1136年(绍兴六年)
|
||||
|
||||
## 艺术特色
|
||||
|
||||
1. **情感激昂**:全词情感激越,气势磅礴
|
||||
2. **对仗工整**:"三十功名尘与土,八千里路云和月"等句对仗精妙
|
||||
3. **意象鲜明**:运用"怒发冲冠"、"仰天长啸"等生动意象
|
||||
4. **爱国情怀**:表达了强烈的爱国主义精神和民族气节
|
||||
|
||||
## 历史影响
|
||||
|
||||
这首词成为中华民族爱国主义精神的象征之一,激励了无数仁人志士为国家和民族奋斗。
|
||||
|
||||
---
|
||||
|
||||
*注:此版本为经典传世版本,收录于《全宋词》。*
|
||||
58
知你客服17号能力文档.md
Normal file
58
知你客服17号能力文档.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 知你客服 17 号 · 能力说明(C:主动排障闭环执行)
|
||||
|
||||
## 定位
|
||||
|
||||
在 **知你客服15号**(可持续多步工具执行)基础上,增加 **C 能力**:
|
||||
遇到问题时优先主动完成「**自检 → 执行 → 验证 → 补救**」闭环,而不是停留在“我去检查一下”。
|
||||
|
||||
## 核心增强
|
||||
|
||||
1. **主动自检**
|
||||
- 任务开始先确认必要前提(工作区、目标路径、文件是否存在、输入是否完整)。
|
||||
2. **主动执行**
|
||||
- 明确调用工具推进,不只给计划语句。
|
||||
3. **结果验证**
|
||||
- `file_write` 后强制回读或关键字段校验,避免“说已写入但未落盘”。
|
||||
4. **失败补救**
|
||||
- 单步失败时至少尝试 1-2 个替代方案(路径、文件名、编码、模式)。
|
||||
5. **最小化追问**
|
||||
- 仅在确实缺少关键输入时才向用户追问;否则优先自助完成。
|
||||
|
||||
## 工具策略
|
||||
|
||||
- 默认本地闭环:`system_info`、`file_read`、`file_write`、`text_analyze`、`json_process`。
|
||||
- `http_request` 仅在用户明确要求联网或本地信息不足时使用。
|
||||
- `database_query` 仅允许 `SELECT`。
|
||||
- 古文/常识续写(如《三字经》补全)优先直接生成并落盘,不依赖联网。
|
||||
|
||||
## 运行参数
|
||||
|
||||
- `llm-unified.max_tool_iterations = 22`(默认)。
|
||||
- 工具列表与 15 号一致(全量内置工具)。
|
||||
|
||||
## 创建/更新脚本
|
||||
|
||||
```text
|
||||
backend/scripts/create_zhini_kefu_17.py
|
||||
```
|
||||
|
||||
用法:
|
||||
|
||||
```powershell
|
||||
cd backend
|
||||
.\venv\Scripts\python.exe scripts/create_zhini_kefu_17.py
|
||||
```
|
||||
|
||||
环境变量:
|
||||
- `PLATFORM_BASE_URL`(默认 `http://127.0.0.1:8037`)
|
||||
- `PLATFORM_USERNAME`(默认 `admin`)
|
||||
- `PLATFORM_PASSWORD`(默认 `123456`)
|
||||
- `SOURCE_AGENT_NAME`(默认 `知你客服15号`)
|
||||
- `TARGET_NAME`(默认 `知你客服17号`)
|
||||
|
||||
## 验收建议
|
||||
|
||||
1. 让 17 号执行“创建并写入文件,再读回验证”的任务。
|
||||
2. 让 17 号执行“已有文件续写并覆盖指定段落”的任务。
|
||||
3. 构造轻微异常(路径不规范、文件已存在)确认其会主动补救而非直接停住。
|
||||
|
||||
Reference in New Issue
Block a user