feat(ui):优化在线体验功能

This commit is contained in:
chengqian committed 2026-09-15 19:20:15 +08:00
1 parent f3e930d027
commit 4c212aeb04
14 files changed
+1703 -139

No files matched your search

+1
View File
@@ -473,6 +473,7 @@ cmd(路由注册)
| 对比分析 | `docs/new-api-vs-sub2api对比分析报告.md` | 两者差异 |
| 大模型实施方案 | `docs/大模型完整实施方案-v2.1.md` | 大模型代理层设计 |
| API 格式参考 | `docs/reference/api-format-reference.md` | JSON 示例、SSE 格式、错误映射、中间件差异 |
| 协议转换语料采集 | `docs/协议转换语料采集手册.md` | `corpus-extract` 命令用法:从渠道调试日志提取真实流量语料喂给协议转换测试 |
| GoFrame 使用规范 | `docs/reference/goframe-conventions.md` | 框架使用规范 + 已修复的框架 bug 记录 |
+10 -5
View File
@@ -100,13 +100,18 @@ func GetTenantAuditLevel(ctx context.Context, tenantID int64) string {
// ApplyAuditLevel 根据审计级别处理请求体和响应体,返回处理后的内容。
func ApplyAuditLevel(level string, requestBody, responseBody string, isStream bool, path string) (req, resp string) {
req = requestBody
resp = responseBody
// 多模态请求(对话内联图片/音频、视频参考图)的 base64 载荷先剥离成长度占位:
// multipart 体已在 relay handler 侧被挡成 "[binary body omitted]",但内联在 JSON
// 里的 data URL 会原样落库,单条就能把 aud_request_logs 撑到几 MB。
// 此处是对话与异步任务两条审计链路的唯一收口点(DataProviderImpl.RecordAudit),
// 在这里剥离即可同时覆盖。none 级别直接丢弃,无需白做一次剥离。
if level == AuditLevelNone {
return "", ""
}
req = StripInlineMedia(requestBody)
resp = StripInlineMedia(responseBody)
switch level {
case AuditLevelNone:
req = ""
resp = ""
case AuditLevelQuestionOnly:
resp = ""
case AuditLevelMasked:
+97
View File
@@ -0,0 +1,97 @@
package common
import (
"bytes"
"strconv"
"strings"
)
// 内联媒体(data URL)处理工具。
//
// 多模态请求体里的图片/音频以 `data:<mime>;base64,<载荷>` 的形式内联在 JSON 中,
// 单个载荷动辄数 MB。这类载荷对三条链路都是纯负担:
// - 预扣估算按字节折算会把一张图估成几十万 token,直接冻穿租户余额
// - 审计落库会把整段 base64 灌进 aud_request_logs
// - 敏感词匹配白扫几 MB,还可能在 base64 串里误命中
//
// 这里提供统一的扫描与剥离能力,三处共用,避免各写一份。
// inlineMediaMarker data URL 中 base64 载荷的起始标记。
const inlineMediaMarker = ";base64,"
// inlineMediaMinPayload 认定为内联媒体的最小载荷长度。
// 真实的图片/音频 base64 远大于此,设下限是为了不误伤正文里恰好出现该标记的短文本。
const inlineMediaMinPayload = 64
// ScanInlineMediaBytes 扫描内容中的 data URL base64 载荷,返回载荷总字节数与媒体个数。
//
// 纯字节扫描不做 JSON 反序列化——调用点在请求热路径上,且请求体可能有数 MB。
// base64 字母表不含 `"` 与 `\`,故以二者作为 JSON 字符串内载荷的终止符。
func ScanInlineMediaBytes(b []byte) (payloadBytes, count int) {
marker := []byte(inlineMediaMarker)
rest := b
for {
idx := bytes.Index(rest, marker)
if idx < 0 {
return
}
rest = rest[idx+len(marker):]
end := bytes.IndexAny(rest, "\"\\")
if end < 0 {
end = len(rest)
}
if end >= inlineMediaMinPayload {
payloadBytes += end
count++
}
rest = rest[end:]
}
}
// StripInlineMediaBytes 是 StripInlineMedia 的字节切片版本。
// 未命中内联媒体时原样返回入参切片,不产生任何拷贝——请求热路径上的绝大多数
// 请求体是纯文本,这条快路径让它们零开销通过。
func StripInlineMediaBytes(b []byte) []byte {
if !bytes.Contains(b, []byte(inlineMediaMarker)) {
return b
}
return []byte(StripInlineMedia(string(b)))
}
// StripInlineMedia 把 base64 载荷替换成长度占位,保留 mime 前缀便于排查:
//
// data:image/png;base64,iVBORw0KGgo... → data:image/png;base64,<省略 2097152 字节>
//
// 未命中任何内联媒体时原样返回,不产生额外分配。
func StripInlineMedia(s string) string {
if !strings.Contains(s, inlineMediaMarker) {
return s
}
var sb strings.Builder
rest := s
for {
idx := strings.Index(rest, inlineMediaMarker)
if idx < 0 {
sb.WriteString(rest)
break
}
head := idx + len(inlineMediaMarker)
sb.WriteString(rest[:head])
rest = rest[head:]
end := strings.IndexAny(rest, "\"\\")
if end < 0 {
end = len(rest)
}
if end >= inlineMediaMinPayload {
sb.WriteString("<省略 ")
sb.WriteString(strconv.Itoa(end))
sb.WriteString(" 字节>")
} else {
sb.WriteString(rest[:end])
}
rest = rest[end:]
}
return sb.String()
}
+119
View File
@@ -0,0 +1,119 @@
package common
import (
"strings"
"testing"
)
// bigPayload 构造一段长度足够被认定为内联媒体的 base64 载荷。
func bigPayload(n int) string {
return strings.Repeat("A", n)
}
func TestScanInlineMediaBytes(t *testing.T) {
payload := bigPayload(400)
cases := []struct {
name string
body string
wantBytes int
wantCount int
}{
{"纯文本", `{"model":"gpt-4o","messages":[{"role":"user","content":"你好"}]}`, 0, 0},
{"空体", "", 0, 0},
{
"单图",
`{"content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,` + payload + `"}}]}`,
400, 1,
},
{
"双图",
`{"a":"data:image/png;base64,` + payload + `","b":"data:image/jpeg;base64,` + payload + `"}`,
800, 2,
},
{
"音频",
`{"input_audio":{"data":"data:audio/mp3;base64,` + payload + `","format":"mp3"}}`,
400, 1,
},
// 载荷过短:正文里恰好出现该标记,不应被当作媒体扣减
{"短载荷不计入", `{"text":"写法是 ;base64,abc 这样"}`, 0, 0},
// 无结束引号(截断的请求体):载荷取到末尾
{"未闭合", `{"url":"data:image/png;base64,` + payload, 400, 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotBytes, gotCount := ScanInlineMediaBytes([]byte(c.body))
if gotBytes != c.wantBytes || gotCount != c.wantCount {
t.Errorf("ScanInlineMediaBytes() = (%d, %d), want (%d, %d)",
gotBytes, gotCount, c.wantBytes, c.wantCount)
}
})
}
}
func TestStripInlineMedia(t *testing.T) {
payload := bigPayload(400)
t.Run("剥离载荷保留 mime 前缀", func(t *testing.T) {
in := `{"url":"data:image/png;base64,` + payload + `","prompt":"描述这张图"}`
got := StripInlineMedia(in)
if strings.Contains(got, payload) {
t.Error("base64 载荷未被剥离")
}
if !strings.Contains(got, "data:image/png;base64,<省略 400 字节>") {
t.Errorf("占位文案不符: %s", got)
}
if !strings.Contains(got, `"prompt":"描述这张图"`) {
t.Error("载荷之外的内容被破坏")
}
})
t.Run("纯文本原样返回", func(t *testing.T) {
in := `{"model":"gpt-4o","messages":[{"role":"user","content":"你好"}]}`
if got := StripInlineMedia(in); got != in {
t.Errorf("StripInlineMedia() = %q, want 原样返回", got)
}
})
t.Run("多个载荷全部剥离", func(t *testing.T) {
in := `{"a":"data:image/png;base64,` + payload + `","b":"data:image/jpeg;base64,` + payload + `"}`
got := StripInlineMedia(in)
if strings.Contains(got, payload) {
t.Error("仍残留 base64 载荷")
}
if n := strings.Count(got, "<省略 400 字节>"); n != 2 {
t.Errorf("占位数量 = %d, want 2", n)
}
})
t.Run("短载荷保持原样", func(t *testing.T) {
in := `{"text":"写法是 ;base64,abc 这样"}`
if got := StripInlineMedia(in); got != in {
t.Errorf("StripInlineMedia() = %q, want 原样返回", got)
}
})
t.Run("剥离后可被字节版复用且无拷贝", func(t *testing.T) {
plain := []byte(`{"content":"纯文本"}`)
got := StripInlineMediaBytes(plain)
if &got[0] != &plain[0] {
t.Error("纯文本路径应原样返回入参切片,不做拷贝")
}
})
}
func TestApplyAuditLevelStripsInlineMedia(t *testing.T) {
payload := bigPayload(400)
body := `{"url":"data:image/png;base64,` + payload + `"}`
req, _ := ApplyAuditLevel(AuditLevelFull, body, "", false, "/v1/chat/completions")
if strings.Contains(req, payload) {
t.Error("full 级别仍把 base64 载荷落库")
}
req, resp := ApplyAuditLevel(AuditLevelNone, body, body, false, "/v1/chat/completions")
if req != "" || resp != "" {
t.Error("none 级别应返回空")
}
}
+11 -4
View File
@@ -94,7 +94,12 @@ func ContentFilter(r *ghttp.Request) {
return
}
result := common.ContentFilter().Check(body)
// 多模态请求体里的 base64 内联媒体先剥离再匹配:几 MB 的 base64 白扫一遍纯属浪费,
// 且 base64 字母表可能恰好拼出敏感词,导致正常的带图请求被误 block。
// 纯文本请求体原样返回,不额外拷贝。
checkBody := common.StripInlineMediaBytes(body)
result := common.ContentFilter().Check(checkBody)
if !result.Matched {
r.Middleware.Next()
return
@@ -105,7 +110,7 @@ func ContentFilter(r *ghttp.Request) {
r.SetCtxVar(CtxKeyContentFilterWords, result.MatchedWords)
// Queue async log write
queueContentFilterLog(r, mode, result.MatchedWords, string(body))
queueContentFilterLog(r, mode, result.MatchedWords, string(checkBody))
switch mode {
case "log":
@@ -115,8 +120,10 @@ func ContentFilter(r *ghttp.Request) {
case "replace":
g.Log().Infof(r.Context(), "[ContentFilter] replaced words: %v", result.MatchedWords)
r.SetCtxVar(CtxKeyContentFilterReplaced, true)
r.SetCtxVar(CtxKeyContentFilterOriginal, string(body))
r.SetCtxVar(CtxKeyContentFilterFiltered, result.FilteredText)
r.SetCtxVar(CtxKeyContentFilterOriginal, string(checkBody))
// 替换后的请求体必须作用在原始 body 上——剥离版本丢了内联媒体,
// 一旦被下游拿去改写上游请求就会把图片/音频弄没。命中才多跑这一次,代价可接受。
r.SetCtxVar(CtxKeyContentFilterFiltered, common.ContentFilter().Check(body).FilteredText)
r.Middleware.Next()
case "block":
+57
View File
@@ -2,6 +2,7 @@ package handler
import (
"context"
"strings"
"testing"
"github.com/qianfree/team-api/relay/common"
@@ -60,3 +61,59 @@ func TestResolveRelayModel(t *testing.T) {
}
})
}
// TestEstimateInputTokens 多模态请求体的预扣估算:base64 内联媒体必须按固定成本计入,
// 不能按字节折算——一张 2MB 的图按 len/4 会被估成约 70 万 token,预扣直接冻穿余额。
func TestEstimateInputTokens(t *testing.T) {
// 模拟 2MB 的 base64 图片载荷
payload := strings.Repeat("A", 2<<20)
cases := []struct {
name string
body string
want int
}{
{
"纯文本按字节折算",
strings.Repeat("x", 400),
100,
},
{
"空体",
"",
0,
},
{
// 文本骨架 40 字节 + 1 个媒体
"单图不按字节折算",
`{"url":"data:image/png;base64,` + payload + `"}`,
(len(`{"url":"data:image/png;base64,`+`"}`))/4 + inlineMediaTokenCost,
},
{
"双图按个数累加",
`{"a":"data:image/png;base64,` + payload + `","b":"data:image/png;base64,` + payload + `"}`,
(len(`{"a":"data:image/png;base64,`+`","b":"data:image/png;base64,`+`"}`))/4 + 2*inlineMediaTokenCost,
},
{
// 畸形 data URL(无 base64 标记)退化为纯文本路径
"畸形 data URL",
`{"url":"data:image/png,` + strings.Repeat("A", 400) + `"}`,
len(`{"url":"data:image/png,`+`"}`)/4 + 100,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := estimateInputTokens([]byte(c.body)); got != c.want {
t.Errorf("estimateInputTokens() = %d, want %d", got, c.want)
}
})
}
// 回归保护:2MB 图片的估算必须远低于按字节折算的量级
naive := len(`{"url":"data:image/png;base64,`+payload+`"}`) / 4
got := estimateInputTokens([]byte(`{"url":"data:image/png;base64,` + payload + `"}`))
if got >= naive/10 {
t.Errorf("2MB 图片估算 %d tokens,与按字节折算的 %d 处于同一量级,预扣仍会冻穿余额", got, naive)
}
}
@@ -80,6 +80,10 @@ const icons: Record<string, string> = {
users: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z',
// Files & Documents
photo: 'M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z',
paperclip: 'M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.112 2.13',
musicalNote: 'M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z',
film: 'M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0112 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5',
document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
bookOpen: 'M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25',
clipboard: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z',
+93 -13
View File
@@ -23,15 +23,18 @@ interface ModelItem {
async_image?: boolean
// 图片模型同步端点是否可用(「仅异步」厂商为 false)
image_sync_supported?: boolean
// 模型能力特性 JSON(如 {"vision":true,"audio_input":true}),用于门控附件上传
capabilities?: string
}
// 每个 Tab 的图标,用于分段控件左侧的视觉锚点
const tabs = [
{ key: 'chat', label: '对话' },
{ key: 'image', label: '图像' },
{ key: 'video', label: '视频' },
{ key: 'audio', label: '语音' },
{ key: 'embedding', label: '嵌入' },
{ key: 'rerank', label: '重排' },
{ key: 'chat', label: '对话', icon: 'chat' },
{ key: 'image', label: '图像', icon: 'photo' },
{ key: 'video', label: '视频', icon: 'film' },
{ key: 'audio', label: '语音', icon: 'musicalNote' },
{ key: 'embedding', label: '嵌入', icon: 'cube' },
{ key: 'rerank', label: '重排', icon: 'chart' },
]
const activeTab = ref('chat')
@@ -59,6 +62,13 @@ const modelsLoading = ref(false)
let loadingTimer: number | null = null
let loadSeq = 0
// 当前 Key 的显示名与掩码前缀,供工具栏的 Key 胶囊展示
const selectedKeyLabel = computed(() => {
const key = apiKeys.value.find(k => k.id === selectedKeyId.value)
if (!key) return ''
return `${key.name} · ${key.key_prefix}···`
})
// 按右上角选中的 API Key 拉取其可用模型,使左侧模型列表跟随 Key 切换。
// 切换期间保留旧列表避免空白闪烁;加载提示延迟 200ms 才出现,请求更快时
// 完全不显示,杜绝 spinner 瞬闪;loadSeq 丢弃过期响应,防止快速切换 Key 时旧结果覆盖。
@@ -93,15 +103,19 @@ watch(selectedKeyId, loadModels, { immediate: true })
<template>
<div>
<!-- 紧凑工具栏:左侧模型类型标签 + 右侧 API Key 单行收纳 -->
<!-- 统一工具栏:左侧模型类型分段控件,右侧调用身份(API Key)与计费提示。
计费提示从原对话区黄色横幅迁移至此,不再占用对话区高度。 -->
<div class="card mb-3">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3 sm:px-5">
<!-- 左侧:模型类型标签 -->
<div class="tabs flex-wrap">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2.5 sm:px-4">
<!-- 左侧:模型类型分段控件 -->
<div class="tabs flex-wrap" role="tablist">
<button v-for="tab in tabs" :key="tab.key"
class="tab"
role="tab"
class="tab flex items-center gap-1.5"
:class="{ 'tab-active': activeTab === tab.key }"
:aria-selected="activeTab === tab.key"
@click="activeTab = tab.key">
<Icon :name="tab.icon" size="xs" />
{{ tab.label }}
</button>
</div>
@@ -116,6 +130,15 @@ watch(selectedKeyId, loadModels, { immediate: true })
<!-- 右侧:API Key 区域 -->
<div class="ml-auto flex shrink-0 items-center gap-2">
<!-- 真实计费提示:悬停展示完整说明 -->
<span
class="hidden items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700 xl:inline-flex"
title="Playground 模式 — 使用真实 API Key 调用,产生实际费用"
>
<Icon name="bolt" size="xs" />
真实计费
</span>
<!-- 加载中 -->
<template v-if="keyLoading">
<div class="spinner h-4 w-4 text-primary-600"></div>
@@ -129,12 +152,15 @@ watch(selectedKeyId, loadModels, { immediate: true })
</div>
<!-- Key 选择器 -->
<template v-else>
<span class="text-xs font-medium text-gray-400">API Key</span>
<div class="w-56">
<div class="key-picker">
<Icon name="key" size="xs" class="key-picker-icon" />
<n-select
:value="selectedKeyId"
:options="apiKeys.map(k => ({ label: `${k.name} (${k.key_prefix}...)`, value: k.id }))"
placeholder="选择 API Key"
size="small"
class="key-picker-select"
:title="selectedKeyLabel"
@update:value="selectKey"
/>
</div>
@@ -156,3 +182,57 @@ watch(selectedKeyId, loadModels, { immediate: true })
</template>
</div>
</template>
<style scoped>
/* 分段控件:激活项用主色文字与更清晰的投影,提升可辨识度 */
.tab {
display: inline-flex;
align-items: center;
}
.tab-active {
color: #0d9488;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08), 0 0 0 1px rgba(20, 184, 166, 0.18);
}
/* API Key 胶囊:key 图标 + 紧凑下拉,与左侧分段控件在同一视觉高度上 */
.key-picker {
display: flex;
align-items: center;
gap: 0.25rem;
height: 2.25rem;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
background: #fff;
padding-left: 0.55rem;
padding-right: 0.25rem;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition: border-color 180ms ease, box-shadow 180ms ease;
}
.key-picker:hover,
.key-picker:focus-within {
border-color: rgba(20, 184, 166, 0.45);
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.08);
}
.key-picker-icon {
color: #f59e0b;
flex-shrink: 0;
}
.key-picker-select {
width: 12.5rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 639px) {
.key-picker-select {
width: 9rem;
}
}
</style>
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import Icon from '@/components/common/Icon.vue'
import { formatBytes, type Attachment } from './useAttachments'
/**
* 附件预览带:位于输入框上方,承载上传入口与已选附件的缩略展示。
* 点击某个附件 = 在输入框光标处插入它的 [@名称] 标记(比打 @ 更快的路径)。
*/
const props = withDefaults(defineProps<{
attachments: Attachment[]
/** input 的 accept 属性,如 "image/*,audio/*" */
accept: string
max: number
disabled?: boolean
/** 禁用原因,作为上传按钮的 title 与旁注展示 */
disabledReason?: string
/**
* 视觉变体:
* - boxed(默认):带虚线框的独立附件带,含上传按钮与提示文案
* - bare:只渲染附件缩略片,无边框无上传按钮,供父组件把输入区整合成一体时使用
* (此变体下由父组件通过 ref 调用 pick() 触发选择文件)
*/
variant?: 'boxed' | 'bare'
}>(), {
disabled: false,
disabledReason: '',
variant: 'boxed',
})
const emit = defineEmits<{
add: [files: File[]]
remove: [id: number]
insert: [name: string]
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
const full = computed(() => props.attachments.length >= props.max)
const uploadDisabled = computed(() => props.disabled || full.value)
function pick() {
if (uploadDisabled.value) return
fileInput.value?.click()
}
function onPicked(e: Event) {
const input = e.target as HTMLInputElement
if (input.files?.length) emit('add', Array.from(input.files))
// 置空 value,否则连续选同一个文件不会再触发 change
input.value = ''
}
function onDrop(e: DragEvent) {
dragging.value = false
if (uploadDisabled.value) return
const files = Array.from(e.dataTransfer?.files || [])
if (files.length) emit('add', files)
}
function onDragOver() {
if (!uploadDisabled.value) dragging.value = true
}
// bare 变体下不渲染上传按钮,选择文件由父组件调用此方法触发
defineExpose({ pick })
</script>
<template>
<!-- bare:只渲染附件缩略片,无边框、无上传入口,融入父组件的输入容器 -->
<div v-if="variant === 'bare'" class="flex flex-wrap items-center gap-1.5">
<div
v-for="att in attachments"
:key="att.id"
class="group relative flex items-center gap-1.5 rounded-lg border border-gray-200 bg-gray-50 py-0.5 pl-0.5 pr-6 transition-colors duration-150 hover:border-primary-300 hover:bg-primary-50/40"
>
<button
class="flex items-center gap-1.5 cursor-pointer"
:title="`${att.fileName} · 点击插入 [@${att.name}]`"
@click="emit('insert', att.name)"
>
<img
v-if="att.kind === 'image'"
:src="att.previewUrl"
class="h-6 w-6 rounded object-cover"
alt=""
/>
<span v-else class="flex h-6 w-6 items-center justify-center rounded bg-white text-gray-500">
<Icon :name="att.kind === 'audio' ? 'musicalNote' : 'film'" size="xs" />
</span>
<span class="flex items-baseline gap-1 leading-none">
<span class="text-xs font-medium text-gray-700">{{ att.name }}</span>
<span class="text-[10px] text-gray-400">{{ formatBytes(att.bytes) }}</span>
</span>
</button>
<button
class="absolute right-0.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-gray-300 transition-colors duration-150 hover:bg-white hover:text-red-500"
title="移除"
@click="emit('remove', att.id)"
>
<Icon name="x" size="xs" />
</button>
</div>
<!-- 文件选择器仍需挂载,供父组件调 pick() 打开 -->
<input
ref="fileInput"
type="file"
multiple
class="hidden"
:accept="accept"
@change="onPicked"
/>
</div>
<div
v-else
class="rounded-xl border border-dashed px-3 py-2 transition-colors duration-150"
:class="dragging ? 'border-primary-400 bg-primary-50/60' : 'border-gray-200 bg-gray-50/60'"
@dragover.prevent="onDragOver"
@dragleave="dragging = false"
@drop.prevent="onDrop"
>
<div class="flex flex-wrap items-center gap-2">
<!-- 已选附件 -->
<div
v-for="att in attachments"
:key="att.id"
class="group relative flex items-center gap-2 rounded-lg border border-gray-200 bg-white py-1 pl-1 pr-7 transition-colors duration-150 hover:border-primary-300"
>
<button
class="flex items-center gap-2 cursor-pointer"
:title="`${att.fileName} · 点击插入 [@${att.name}]`"
@click="emit('insert', att.name)"
>
<img
v-if="att.kind === 'image'"
:src="att.previewUrl"
class="h-8 w-8 rounded object-cover"
alt=""
/>
<span v-else class="flex h-8 w-8 items-center justify-center rounded bg-gray-100 text-gray-500">
<Icon :name="att.kind === 'audio' ? 'musicalNote' : 'film'" size="sm" />
</span>
<span class="flex flex-col items-start leading-tight">
<span class="text-xs font-medium text-gray-700">{{ att.name }}</span>
<span class="text-[11px] text-gray-400">{{ formatBytes(att.bytes) }}</span>
</span>
</button>
<button
class="absolute right-1 top-1 rounded p-0.5 text-gray-300 transition-colors duration-150 hover:bg-gray-100 hover:text-red-500"
title="移除"
@click="emit('remove', att.id)"
>
<Icon name="x" size="xs" />
</button>
</div>
<!-- 上传入口 -->
<button
class="btn btn-secondary btn-sm"
:disabled="uploadDisabled"
:title="disabled ? disabledReason : (full ? `最多 ${max} 个附件` : '可拖拽文件或在输入框粘贴图片')"
@click="pick"
>
<Icon name="paperclip" size="sm" />
上传附件
</button>
<span v-if="disabled && disabledReason" class="text-xs text-amber-600">{{ disabledReason }}</span>
<span v-else-if="!attachments.length" class="text-xs text-gray-400">
可拖拽文件到此处,或在输入框直接粘贴图片
</span>
</div>
<input
ref="fileInput"
type="file"
multiple
class="hidden"
:accept="accept"
@change="onPicked"
/>
</div>
</template>
@@ -5,6 +5,11 @@ import { calculateCost } from './calculateCost'
import Icon from '@/components/common/Icon.vue'
import MarkdownRenderer from '@/components/common/MarkdownRenderer.vue'
import BaseSelect from '../../../components/common/BaseSelect.vue'
import AttachmentBar from './AttachmentBar.vue'
import PromptInput from './PromptInput.vue'
import { useAttachments, formatBytes, MAX_TOTAL_BYTES, type AttachmentKind } from './useAttachments'
import { compileChatParts, type ContentPart, type AttachmentPreview } from './attachmentParts'
import { toast } from '@/utils/toast'
interface ModelItem {
model_id: string
@@ -14,6 +19,8 @@ interface ModelItem {
per_request_price?: number | null
input_price?: number | null
output_price?: number | null
// 能力特性 JSON(如 {"vision":true}),决定附件上传是否开放
capabilities?: string
}
const props = defineProps<{
models: ModelItem[]
@@ -63,13 +70,135 @@ const imageSizeSelectOptions = computed(() => imageSizeOptions.map(s => ({ value
interface ChatMessage {
role: string
/** 展示文本:[@图片1] 已换成纯文字标签 */
content: string
reasoningContent?: string
/** 多模态消息发给 API 的完整形态;纯文本消息为空,回传时退化为 content 字符串 */
parts?: ContentPart[]
/** 气泡里的附件缩略图 */
previews?: AttachmentPreview[]
}
const messages = ref<ChatMessage[]>([])
const inputMessage = ref('')
const tokenUsage = reactive({ prompt: 0, completion: 0, reasoning: 0, total: 0, cost: '' })
// ── 附件 ──
// capabilities 是 admin 侧存的 JSON 对象字符串,解析失败按「无能力」处理
const modelCapabilities = computed<Record<string, boolean>>(() => {
const raw = selectedModelItem.value?.capabilities
if (!raw) return {}
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
})
// 对话只收图片与音频:ContentPart.File 虽在 DTO 里,但各协议转换器普遍不处理,
// 传文档会被静默丢弃还照样计费。类型随模型能力动态收放。
const attachmentKinds = computed<AttachmentKind[]>(() => {
const kinds: AttachmentKind[] = []
if (modelCapabilities.value.vision) kinds.push('image')
if (modelCapabilities.value.audio_input) kinds.push('audio')
return kinds
})
const {
list: attachmentList,
totalBytes: attachmentBytes,
accept: acceptTypes,
max: attachmentMax,
addFiles: addAttachments,
remove: removeAttachment,
clear: clearAttachments,
} = useAttachments({ kinds: () => attachmentKinds.value, max: 9 })
const promptInput = ref<InstanceType<typeof PromptInput> | null>(null)
const attachmentBar = ref<InstanceType<typeof AttachmentBar> | null>(null)
// 输入容器的拖拽高亮状态(附件栏在 bare 变体下不再自带拖放区,统一由输入容器承接)
const dragging = ref(false)
// 空态快捷提示词:点一下直接填进输入框,减少首次使用的空白焦虑
const quickPrompts = [
'用三句话介绍你能做什么',
'写一个 Python 快速排序函数',
'把下面这段话翻译成英文:',
]
// 非 vision / 非音频输入的模型禁用上传:传了也是白花 token 再拿上游报错
const uploadDisabled = computed(() => attachmentKinds.value.length === 0)
const uploadDisabledReason = computed(() =>
uploadDisabled.value ? '该模型不支持图片/音频输入' : '',
)
// 历史消息里的 base64 每轮都会重新发送,多轮下很容易撞后端 8MB 的 body 上限,
// 这里把「历史 + 待发」的合计体积摆到台面上,并在超限时拦住发送。
const historyAttachmentBytes = computed(() =>
messages.value.reduce((sum, m) => {
if (!m.parts) return sum
return sum + m.parts.reduce((s, p) => {
if (p.type === 'image_url') return s + (p.image_url?.url.length || 0)
if (p.type === 'input_audio') return s + (p.input_audio?.data.length || 0)
return s
}, 0)
}, 0),
)
const pendingBytes = computed(() => historyAttachmentBytes.value + attachmentBytes.value)
const overBudget = computed(() => pendingBytes.value > MAX_TOTAL_BYTES)
function insertMention(name: string) {
promptInput.value?.insertMention(name)
}
// 粘贴图片走同一条上传链路;模型不支持时给出明确原因,而非落到「不支持的文件类型」
function onPasteFiles(files: File[]) {
if (uploadDisabled.value) {
toast.warning(uploadDisabledReason.value)
return
}
addAttachments(files)
}
// 输入容器承接拖放:与粘贴共用同一校验与上传链路
function onDropFiles(e: DragEvent) {
dragging.value = false
if (uploadDisabled.value) {
toast.warning(uploadDisabledReason.value)
return
}
if (e.dataTransfer?.files?.length) addAttachments(Array.from(e.dataTransfer.files))
}
function openFilePicker() {
if (uploadDisabled.value) {
toast.warning(uploadDisabledReason.value)
return
}
attachmentBar.value?.pick()
}
// 一键清空附件(模型切换导致能力不匹配时尤其有用)
function clearAllAttachments() {
clearAttachments()
}
// Temperature 滑块的已填充比例,用于把轨道左段染成主色
const rangePercent = computed(() => `${(params.temperature / 2) * 100}%`)
// 恢复参数默认值:只重置生成参数,不动已选模型与对话内容
function resetParams() {
params.temperature = 0.7
params.maxTokens = 2048
params.systemPrompt = ''
params.thinking = false
params.stream = true
imageConfig.aspectRatio = '1:1'
imageConfig.imageSize = '1K'
toast.success('参数已恢复默认值')
}
// 记录每条 assistant 消息的思考内容折叠状态(用 message index 作为 key)
const expandedThinking = ref<Record<number, boolean>>({})
@@ -94,7 +223,7 @@ function scrollToBottom(force = false) {
})
}
function buildRequestBody(apiMessages: { role: string; content: string }[]) {
function buildRequestBody(apiMessages: { role: string; content: string | ContentPart[] }[]) {
const body: Record<string, any> = {
model: selectedModel.value,
messages: apiMessages,
@@ -244,22 +373,38 @@ async function sendStream(requestBody: Record<string, any>, assistantIdx: number
}
async function sendMessage() {
const content = inputMessage.value.trim()
if (!content || !selectedModel.value || sending.value) return
const raw = inputMessage.value.trim()
if ((!raw && !attachmentList.value.length) || !selectedModel.value || sending.value) return
if (overBudget.value) {
toast.error(`附件合计 ${formatBytes(pendingBytes.value)} 已超过 ${formatBytes(MAX_TOTAL_BYTES)},请清空对话或删除部分附件`)
return
}
if (params.systemPrompt && messages.value.length === 0) {
messages.value.push({ role: 'system', content: params.systemPrompt })
}
messages.value.push({ role: 'user', content })
// 正文里的 [@图片1] 换成文字标签,并在同一位置插入对应的 content part;
// 未被引用的附件追加到末尾。纯文本消息不带 parts,回传时退化为字符串。
const compiled = compileChatParts(raw, attachmentList.value)
messages.value.push({
role: 'user',
content: compiled.displayText,
parts: attachmentList.value.length ? compiled.parts : undefined,
previews: attachmentList.value.length ? compiled.previews : undefined,
})
inputMessage.value = ''
clearAttachments()
sending.value = true
const assistantIdx = messages.value.length
messages.value.push({ role: 'assistant', content: '', reasoningContent: '' })
scrollToBottom()
const apiMessages = messages.value.slice(0, assistantIdx).map(m => ({ role: m.role, content: m.content }))
const apiMessages = messages.value
.slice(0, assistantIdx)
.map(m => ({ role: m.role, content: (m.parts ?? m.content) as string | ContentPart[] }))
const requestBody = buildRequestBody(apiMessages)
try {
@@ -279,6 +424,7 @@ async function sendMessage() {
function clearChat() {
messages.value = []
clearAttachments()
tokenUsage.prompt = 0
tokenUsage.completion = 0
tokenUsage.reasoning = 0
@@ -289,155 +435,313 @@ function clearChat() {
</script>
<template>
<!-- 高度偏移 ≈ 顶栏与页边距(约128px) + 上方工具栏卡片(约116px),保证对话卡片完整落在视口内 -->
<div class="flex flex-col lg:h-[calc(100vh-15.5rem)] lg:overflow-hidden">
<!-- grid-rows minmax(0,1fr) 强制行高等于容器高,配合左列内部滚动,避免行高被参数卡撑破导致底部输入框被裁切 -->
<div class="grid grid-cols-1 lg:grid-cols-4 lg:grid-rows-[minmax(0,1fr)] gap-6 lg:min-h-0 lg:flex-1">
<!-- 高度偏移 ≈ 顶栏与页边距 + 上方工具栏卡片,保证对话卡片完整落在视口内 -->
<div class="flex flex-col lg:h-[calc(100vh-15rem)] lg:overflow-hidden">
<!-- grid-rows minmax(0,1fr) 强制行高等于容器高,左列卡片内部滚动,
使参数面板与对话区等高对齐,不再出现左栏半截卡片下的大片空白。
左列 22rem:参数区控件较多(模型下拉、Max Tokens、System Prompt、两个开关),
19rem 下 System Prompt 与下拉框偏窄,加宽后可读性更好 -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] lg:grid-rows-[minmax(0,1fr)] lg:min-h-0 lg:flex-1">
<!-- Left: Parameters -->
<div class="lg:col-span-1 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<div class="card">
<div class="card-header">
<h3 class="text-sm font-semibold text-gray-900">参数配置</h3>
<div class="flex lg:col-span-1 lg:min-h-0">
<div class="card flex w-full flex-col overflow-hidden lg:h-full">
<div class="flex shrink-0 items-center justify-between border-b border-gray-100 px-4 py-3">
<div class="flex items-center gap-2">
<Icon name="cog" size="sm" class="text-primary-500" />
<h3 class="text-sm font-semibold text-gray-900">参数配置</h3>
</div>
<button
class="flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-gray-400 transition-colors duration-150 hover:bg-gray-50 hover:text-primary-600"
title="恢复默认参数"
@click="resetParams"
>
<Icon name="refresh" size="xs" />
重置
</button>
</div>
<div class="card-body space-y-4">
<div class="flex-1 space-y-4 overflow-y-auto px-4 py-4">
<!-- 模型 -->
<div>
<label class="input-label">模型</label>
<div class="mb-2 flex items-center justify-between">
<label class="text-xs font-semibold text-gray-500">模型</label>
<span class="text-[11px] text-gray-400">共 {{ models.length }} 个可用</span>
</div>
<BaseSelect v-model="selectedModel" :options="modelOptions" />
<div v-if="isImageModel" class="mt-1.5 flex items-center gap-1.5">
<div v-if="isImageModel" class="mt-2 flex items-center gap-1.5">
<span class="badge badge-primary">图片模型</span>
<span class="text-xs text-gray-500">支持生成图片</span>
</div>
</div>
<div>
<label class="input-label">Temperature: {{ params.temperature.toFixed(1) }}</label>
<input v-model.number="params.temperature" type="range" min="0" max="2" step="0.1" class="w-full" />
<!-- 生成参数 -->
<div class="space-y-4 border-t border-gray-100 pt-4">
<div>
<div class="mb-2 flex items-center justify-between">
<label class="text-xs font-semibold text-gray-500">Temperature</label>
<span class="rounded-md bg-primary-50 px-1.5 py-0.5 font-mono text-xs font-semibold text-primary-600">
{{ params.temperature.toFixed(1) }}
</span>
</div>
<input
v-model.number="params.temperature"
type="range"
min="0"
max="2"
step="0.1"
class="range-slider"
:style="{ '--pct': rangePercent }"
/>
<div class="mt-1.5 flex justify-between text-[11px] text-gray-400">
<span>严谨</span><span>均衡</span><span>发散</span>
</div>
</div>
<div>
<label class="mb-2 block text-xs font-semibold text-gray-500">Max Tokens</label>
<n-input-number v-model:value="params.maxTokens" :min="1" :max="128000" class="w-full" />
</div>
</div>
<div>
<label class="input-label">Max Tokens</label>
<n-input-number v-model:value="params.maxTokens" :min="1" :max="128000" class="w-full" />
</div>
<div>
<label class="input-label">System Prompt</label>
<!-- 系统提示词 -->
<div class="border-t border-gray-100 pt-4">
<label class="mb-2 block text-xs font-semibold text-gray-500">System Prompt</label>
<n-input v-model:value="params.systemPrompt" type="textarea" :rows="3" placeholder="设置系统提示词..." />
</div>
<div v-if="!isImageModel">
<div class="flex items-center justify-between">
<label class="input-label mb-0">深度思考</label>
<n-switch v-model:value="params.thinking" />
<!-- 开关 -->
<div class="space-y-3 border-t border-gray-100 pt-4">
<div v-if="!isImageModel" class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-700">深度思考</p>
<p class="mt-0.5 text-[11px] leading-relaxed text-gray-400">thinking 参数控制,仅部分模型支持</p>
</div>
<n-switch v-model:value="params.thinking" size="small" class="shrink-0" />
</div>
<p class="mt-1 text-xs text-gray-400">通过 thinking 参数控制开关,仅对支持的模型生效</p>
</div>
<div class="flex items-center justify-between">
<label class="input-label mb-0">流式响应</label>
<n-switch v-model:value="params.stream" />
</div>
<template v-if="isImageModel">
<div class="border-t border-gray-100 pt-4">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">图片参数</h4>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-700">流式响应</p>
<p class="mt-0.5 text-[11px] leading-relaxed text-gray-400">逐字返回,实时看到输出过程</p>
</div>
<n-switch v-model:value="params.stream" size="small" class="shrink-0" />
</div>
</div>
<!-- 图片模型专属参数 -->
<div v-if="isImageModel" class="space-y-4 border-t border-gray-100 pt-4">
<p class="text-xs font-semibold text-gray-500">图片参数</p>
<div>
<label class="input-label">宽高比</label>
<label class="mb-2 block text-xs font-medium text-gray-500">宽高比</label>
<BaseSelect v-model="imageConfig.aspectRatio" :options="aspectRatioSelectOptions" />
</div>
<div>
<label class="input-label">分辨率</label>
<label class="mb-2 block text-xs font-medium text-gray-500">分辨率</label>
<BaseSelect v-model="imageConfig.imageSize" :options="imageSizeSelectOptions" />
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Right: Chat -->
<div class="h-[65vh] lg:col-span-3 lg:h-auto lg:min-h-0">
<div class="card flex flex-col h-full overflow-hidden">
<div class="px-6 py-2 bg-amber-50 border-b border-amber-200 text-center">
<span class="text-xs text-amber-700 font-medium">Playground 模式 — 使用真实 API Key 调用,产生实际费用</span>
</div>
<div ref="messagesRef" class="flex-1 overflow-y-auto min-h-0 p-6 space-y-4">
<div v-if="messages.length === 0" class="empty-state">
<div class="empty-state-icon"><Icon name="bookOpen" size="xl" /></div>
<h3 class="empty-state-title">开始对话</h3>
<p class="empty-state-description">选择模型,输入消息,开始测试</p>
<div class="h-[62vh] lg:h-auto lg:min-h-0">
<div class="card flex h-full flex-col overflow-hidden">
<!-- 消息区 -->
<div ref="messagesRef" class="min-h-0 flex-1 overflow-y-auto px-5 py-5 lg:px-6">
<!-- 空态:图标磁贴 + 快捷提示词,给首屏一个视觉锚点 -->
<div v-if="messages.length === 0" class="flex min-h-full flex-col items-center justify-center px-4 text-center">
<div class="mb-5 flex h-16 w-16 items-center justify-center rounded-3xl bg-gradient-to-br from-cyan-400 via-primary-500 to-emerald-500 text-white shadow-glow">
<Icon name="chat" size="lg" />
</div>
<h3 class="text-lg font-semibold text-gray-900">开始对话</h3>
<p class="mt-1.5 max-w-sm text-sm text-gray-500">
<template v-if="selectedModelItem">
当前模型 <span class="font-medium text-gray-700">{{ selectedModelItem.model_name || selectedModelItem.model_id }}</span>,输入消息即可测试
</template>
<template v-else>请先在左侧选择一个模型</template>
</p>
<!-- 快捷提示词:点一下即填入输入框 -->
<div v-if="selectedModel" class="mt-6 flex max-w-lg flex-wrap justify-center gap-2">
<button
v-for="p in quickPrompts"
:key="p"
class="rounded-full border border-gray-200 bg-white px-3 py-1.5 text-xs text-gray-600 transition-all duration-150 hover:-translate-y-0.5 hover:border-primary-300 hover:text-primary-600 hover:shadow-sm"
@click="inputMessage = p; promptInput?.focus()"
>
{{ p }}
</button>
</div>
<p class="mt-7 inline-flex items-center gap-1.5 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-[11px] text-amber-700">
<Icon name="exclamationTriangle" size="xs" />
发送将真实调用模型并产生费用
</p>
</div>
<div v-for="(msg, idx) in messages" :key="idx" class="flex gap-3 animate-slide-up" :class="{ 'justify-end': msg.role === 'user' }">
<!-- Assistant message -->
<div v-if="msg.role === 'assistant'" class="flex gap-3 max-w-[85%]">
<div class="h-8 w-8 rounded-xl bg-primary-100 flex items-center justify-center flex-shrink-0">
<Icon name="cog" size="sm" class="text-primary-600" />
</div>
<div class="min-w-0 flex-1 space-y-2">
<!-- Thinking / Reasoning section -->
<div v-if="msg.reasoningContent" class="rounded-xl border border-amber-200 bg-amber-50/60 overflow-hidden">
<button
class="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-amber-100/60 transition-colors duration-150"
@click="toggleThinking(idx)"
>
<svg
class="h-3.5 w-3.5 text-amber-500 transition-transform duration-200 flex-shrink-0"
:class="{ 'rotate-90': expandedThinking[idx] }"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
<!-- 消息列表 -->
<div v-else class="space-y-5">
<div v-for="(msg, idx) in messages" :key="idx" class="flex gap-3 animate-slide-up" :class="{ 'justify-end': msg.role === 'user' }">
<!-- Assistant message -->
<div v-if="msg.role === 'assistant'" class="flex max-w-[85%] gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-primary-600 text-white shadow-sm shadow-primary-500/25">
<Icon name="cog" size="sm" />
</div>
<div class="min-w-0 flex-1 space-y-2">
<!-- Thinking / Reasoning section -->
<div v-if="msg.reasoningContent" class="overflow-hidden rounded-xl border border-amber-200 bg-amber-50/60">
<button
class="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors duration-150 hover:bg-amber-100/60"
@click="toggleThinking(idx)"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="text-xs font-medium text-amber-700">思考过程</span>
<span class="text-xs text-amber-500">· 消耗 Reasoning Tokens</span>
</button>
<div v-if="expandedThinking[idx]" class="px-3 pb-3">
<div class="rounded-lg bg-white/70 border border-amber-100 px-3 py-2 text-sm text-gray-600 whitespace-pre-wrap leading-relaxed max-h-80 overflow-y-auto">
{{ msg.reasoningContent }}
<svg
class="h-3.5 w-3.5 shrink-0 text-amber-500 transition-transform duration-200"
:class="{ 'rotate-90': expandedThinking[idx] }"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="text-xs font-medium text-amber-700">思考过程</span>
<span class="text-xs text-amber-500">· 消耗 Reasoning Tokens</span>
</button>
<div v-if="expandedThinking[idx]" class="px-3 pb-3">
<div class="max-h-80 overflow-y-auto rounded-lg border border-amber-100 bg-white/70 px-3 py-2 text-sm leading-relaxed text-gray-600 whitespace-pre-wrap">
{{ msg.reasoningContent }}
</div>
</div>
</div>
<!-- Main content -->
<div class="rounded-2xl rounded-tl-md border border-gray-200 bg-white px-4 py-3 shadow-sm">
<MarkdownRenderer :content="msg.content" />
</div>
</div>
</div>
<!-- User message -->
<div v-else-if="msg.role === 'user'" class="max-w-[80%] space-y-1.5">
<!-- 随消息发出的附件:用 data URL 展示(输入区的 objectURL 发送后即被释放) -->
<div v-if="msg.previews?.length" class="flex flex-wrap justify-end gap-1.5">
<div
v-for="p in msg.previews"
:key="p.name"
class="overflow-hidden rounded-xl border border-primary-200 bg-white"
:title="p.name"
>
<img v-if="p.kind === 'image'" :src="p.url" class="h-20 w-20 object-cover" alt="" />
<div v-else class="flex h-20 w-20 flex-col items-center justify-center gap-1 text-gray-500">
<Icon :name="p.kind === 'audio' ? 'musicalNote' : 'film'" size="md" />
<span class="text-[11px]">{{ p.name }}</span>
</div>
</div>
</div>
<!-- Main content -->
<div class="rounded-2xl rounded-tl-sm bg-gray-100 px-4 py-3">
<MarkdownRenderer :content="msg.content" />
<div v-if="msg.content" class="rounded-2xl rounded-tr-md bg-gradient-to-br from-primary-500 to-primary-600 px-4 py-3 text-white shadow-sm shadow-primary-500/20">
<p class="text-sm whitespace-pre-wrap">{{ msg.content }}</p>
</div>
</div>
</div>
<!-- User message -->
<div v-else-if="msg.role === 'user'" class="max-w-[80%]">
<div class="rounded-2xl rounded-tr-sm bg-primary-500 text-white px-4 py-3">
<p class="text-sm whitespace-pre-wrap">{{ msg.content }}</p>
<!-- System message -->
<div v-else-if="msg.role === 'system' && idx === 0" class="text-xs text-gray-400 italic">
System: {{ msg.content.substring(0, 50) }}{{ msg.content.length > 50 ? '...' : '' }}
</div>
</div>
<!-- System message -->
<div v-else-if="msg.role === 'system' && idx === 0" class="text-xs text-gray-400 italic">
System: {{ msg.content.substring(0, 50) }}{{ msg.content.length > 50 ? '...' : '' }}
</div>
</div>
<!-- Sending indicator -->
<div v-if="sending && (params.stream ? (messages.length > 0 && messages[messages.length - 1].role === 'user') : true)" class="flex gap-3">
<div class="h-8 w-8 rounded-xl bg-primary-100 flex items-center justify-center">
<div class="spinner h-4 w-4 text-primary-600"></div>
</div>
<div class="rounded-2xl rounded-tl-sm bg-gray-100 px-4 py-3">
<p class="text-sm text-gray-500">请求中...</p>
<!-- Sending indicator -->
<div v-if="sending && (params.stream ? (messages.length > 0 && messages[messages.length - 1].role === 'user') : true)" class="flex gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-primary-400 to-primary-600">
<div class="spinner h-4 w-4 text-white"></div>
</div>
<div class="rounded-2xl rounded-tl-md border border-gray-200 bg-white px-4 py-3 shadow-sm">
<p class="text-sm text-gray-500">请求中...</p>
</div>
</div>
</div>
</div>
<!-- Token usage bar -->
<div v-if="tokenUsage.total > 0 || tokenUsage.cost" class="px-6 py-2 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500">
<div v-if="tokenUsage.total > 0 || tokenUsage.cost || pendingBytes > 0" class="flex items-center justify-between gap-3 border-t border-gray-100 bg-gray-50/60 px-5 py-2 text-xs text-gray-500 lg:px-6">
<div class="flex items-center gap-4">
<span>Prompt: {{ tokenUsage.prompt }}</span>
<span>Completion: {{ tokenUsage.completion }}</span>
<span v-if="tokenUsage.reasoning > 0" class="text-amber-600">Reasoning: {{ tokenUsage.reasoning }}</span>
<span>Total: {{ tokenUsage.total }}</span>
<span class="flex items-center gap-1.5">Prompt <span class="font-medium text-gray-700">{{ tokenUsage.prompt }}</span></span>
<span class="flex items-center gap-1.5">Completion <span class="font-medium text-gray-700">{{ tokenUsage.completion }}</span></span>
<span v-if="tokenUsage.reasoning > 0" class="flex items-center gap-1.5 text-amber-600">Reasoning <span class="font-medium">{{ tokenUsage.reasoning }}</span></span>
<span class="flex items-center gap-1.5">Total <span class="font-medium text-gray-700">{{ tokenUsage.total }}</span></span>
</div>
<div class="flex items-center gap-4">
<!-- 历史附件每轮都会重发,体积直接决定还能不能继续对话 -->
<span v-if="pendingBytes > 0" :class="overBudget ? 'font-medium text-red-600' : ''">
附件 {{ formatBytes(pendingBytes) }} / {{ formatBytes(MAX_TOTAL_BYTES) }}
</span>
<span v-if="tokenUsage.cost" class="font-medium text-amber-600">{{ tokenUsage.cost }}</span>
</div>
<span v-if="tokenUsage.cost" class="font-medium text-amber-600">{{ tokenUsage.cost }}</span>
</div>
<!-- Input area -->
<div class="p-4 border-t border-gray-200">
<div class="flex gap-3">
<n-input v-model:value="inputMessage" autosize placeholder="输入消息..." class="flex-1" @keydown.enter.exact.prevent="sendMessage" />
<button class="btn btn-primary self-end" :disabled="sending || !inputMessage.trim()" @click="sendMessage">
<Icon name="edit" size="sm" />
发送
</button>
<button class="btn btn-secondary self-end" :disabled="sending" @click="clearChat">
<Icon name="refresh" size="sm" />
清空
</button>
<!-- 输入区:附件片 + 输入框 + 操作按钮整合进同一个圆角盒子,聚焦时整块高亮 -->
<div class="shrink-0 border-t border-gray-100 p-3 lg:p-4">
<div
class="input-shell"
:class="{ 'input-shell-dragging': dragging }"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDropFiles"
>
<!-- 附件缩略片(bare:无边框无上传按钮,融入本容器);
文件选择器仍挂载于此,由下方「附件」按钮通过 ref 触发 -->
<AttachmentBar
ref="attachmentBar"
variant="bare"
:class="attachmentList.length ? 'mb-2' : ''"
:attachments="attachmentList"
:accept="acceptTypes"
:max="attachmentMax"
:disabled="uploadDisabled"
:disabled-reason="uploadDisabledReason"
@add="addAttachments"
@remove="removeAttachment"
@insert="insertMention"
/>
<PromptInput
ref="promptInput"
v-model="inputMessage"
bare
:attachments="attachmentList"
placeholder="输入消息,输入 @ 引用已上传的附件..."
@submit="sendMessage"
@paste="onPasteFiles"
/>
<!-- 操作行 -->
<div class="mt-1.5 flex items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-1">
<button
class="input-tool"
:disabled="uploadDisabled"
:title="uploadDisabled ? uploadDisabledReason : '上传附件(也可拖拽到此处或直接粘贴)'"
@click="openFilePicker"
>
<Icon name="paperclip" size="xs" />
附件
</button>
<button
v-if="attachmentList.length"
class="input-tool"
title="清空已选附件"
@click="clearAllAttachments"
>
<Icon name="x" size="xs" />
清空附件
</button>
<span class="ml-1 hidden truncate text-[11px] text-gray-400 sm:inline">
Enter 发送 · Shift + Enter 换行
</span>
</div>
<div class="flex shrink-0 items-center gap-2">
<button class="btn btn-ghost btn-sm text-gray-500" :disabled="sending" @click="clearChat">
<Icon name="refresh" size="xs" />
清空对话
</button>
<button class="btn btn-primary btn-sm px-4" :disabled="sending || (!inputMessage.trim() && !attachmentList.length)" @click="sendMessage">
<Icon name="edit" size="xs" />
发送
</button>
</div>
</div>
</div>
</div>
</div>
@@ -445,3 +749,93 @@ function clearChat() {
</div>
</div>
</template>
<style scoped>
/* ── 输入容器:把附件片、输入框、操作按钮收进同一个圆角盒子 ── */
.input-shell {
border: 1px solid #e5e7eb;
border-radius: 1rem;
background: #fff;
padding: 0.625rem 0.75rem 0.5rem;
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
}
/* 聚焦与拖拽时整块高亮,而不是只高亮里面的 textarea */
.input-shell:focus-within {
border-color: rgba(20, 184, 166, 0.55);
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.09);
}
.input-shell-dragging {
border-color: #2dd4bf;
border-style: dashed;
background: rgba(240, 253, 250, 0.6);
}
/* ── 输入区内的小工具按钮 ── */
.input-tool {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
color: #6b7280;
font-size: 0.75rem;
line-height: 1.25rem;
transition: background-color 150ms ease, color 150ms ease;
}
.input-tool:hover:not(:disabled) {
background: #f3f4f6;
color: #0d9488;
}
.input-tool:disabled {
cursor: not-allowed;
color: #d1d5db;
}
/* ── Temperature 滑块:替换浏览器默认的紫色原生样式 ── */
.range-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 9999px;
outline: none;
cursor: pointer;
background: linear-gradient(
to right,
#14b8a6 0%,
#14b8a6 var(--pct, 35%),
#e2e8f0 var(--pct, 35%),
#e2e8f0 100%
);
}
.range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 16px;
width: 16px;
border: 2px solid #14b8a6;
border-radius: 9999px;
background: #fff;
box-shadow: 0 2px 6px rgba(13, 148, 136, 0.35);
transition: transform 150ms ease;
}
.range-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.range-slider::-moz-range-thumb {
height: 14px;
width: 14px;
border: 2px solid #14b8a6;
border-radius: 9999px;
background: #fff;
box-shadow: 0 2px 6px rgba(13, 148, 136, 0.35);
}
.range-slider::-moz-range-track {
height: 6px;
border-radius: 9999px;
background: #e2e8f0;
}
.range-slider:focus-visible {
box-shadow: 0 0 0 4px rgba(20, 184, 166, 0.15);
}
</style>
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import Icon from '@/components/common/Icon.vue'
import type { Attachment } from './useAttachments'
/**
* 带 @ 提及的提示词输入框。
*
* 用原生 textarea 而非 n-input:插入标记要精确读写 selectionStart/selectionEnd,
* 隔着组件库拿内部 DOM 既脆又绕。autosize 与样式在此自行实现,视觉对齐 .input。
*/
const props = withDefaults(defineProps<{
modelValue: string
attachments: Attachment[]
/** 关闭后不响应 @,输入框退化为普通 textarea */
mentions?: boolean
placeholder?: string
minRows?: number
maxRows?: number
disabled?: boolean
/** 是否接受粘贴图片(对话/视频都开,纯文本场景可关) */
pasteFiles?: boolean
/**
* 去掉 textarea 自身的边框与内边距,交由父容器的边框统一承载
* (用于把附件栏 + 输入框 + 操作按钮整合成一个视觉整体的输入容器)
*/
bare?: boolean
}>(), {
mentions: true,
placeholder: '',
minRows: 2,
maxRows: 8,
disabled: false,
pasteFiles: true,
bare: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
submit: []
paste: [files: File[]]
}>()
const textarea = ref<HTMLTextAreaElement | null>(null)
const panelOpen = ref(false)
const query = ref('')
const activeIndex = ref(0)
/** 触发条件:光标紧邻的 @ 后面跟着不含空白与方括号的片段 */
const TRIGGER_RE = /@([^\s@[\]]*)$/
const candidates = computed(() => {
if (!query.value) return props.attachments
const q = query.value.toLowerCase()
return props.attachments.filter(
a => a.name.toLowerCase().includes(q) || a.fileName.toLowerCase().includes(q),
)
})
function resize() {
const el = textarea.value
if (!el) return
el.style.height = 'auto'
const lineHeight = 22
const max = props.maxRows * lineHeight + 20
el.style.height = `${Math.min(el.scrollHeight, max)}px`
}
watch(() => props.modelValue, () => nextTick(resize))
// 附件被删光时关掉可能还开着的候选面板
watch(() => props.attachments.length, len => {
if (!len) closePanel()
})
function closePanel() {
panelOpen.value = false
query.value = ''
activeIndex.value = 0
}
function syncPanel() {
if (!props.mentions) return
const el = textarea.value
if (!el) return
const before = props.modelValue.slice(0, el.selectionStart)
const m = TRIGGER_RE.exec(before)
if (!m) {
closePanel()
return
}
panelOpen.value = true
query.value = m[1]
activeIndex.value = 0
}
function onInput(e: Event) {
emit('update:modelValue', (e.target as HTMLTextAreaElement).value)
nextTick(() => {
resize()
syncPanel()
})
}
/** insertMention 把光标前的 @片段替换成 [@名称];无触发片段时在光标处插入 */
function insertMention(name: string) {
const el = textarea.value
if (!el) return
const token = `[@${name}]`
// 从未聚焦过的输入框 selectionStart 恒为 0,插到开头很反直觉,改为追加到末尾
const touched = el.selectionStart > 0 || el.selectionEnd > 0 || document.activeElement === el
const pos = touched ? el.selectionStart : props.modelValue.length
const end = touched ? el.selectionEnd : props.modelValue.length
const before = props.modelValue.slice(0, pos)
const after = props.modelValue.slice(end)
const m = TRIGGER_RE.exec(before)
const head = m ? before.slice(0, m.index) : before
emit('update:modelValue', head + token + after)
closePanel()
nextTick(() => {
const caret = head.length + token.length
el.focus()
el.setSelectionRange(caret, caret)
resize()
})
}
function onKeydown(e: KeyboardEvent) {
if (panelOpen.value && candidates.value.length && !e.isComposing) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % candidates.value.length
return
case 'ArrowUp':
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + candidates.value.length) % candidates.value.length
return
case 'Enter':
case 'Tab':
e.preventDefault()
insertMention(candidates.value[activeIndex.value].name)
return
case 'Escape':
e.preventDefault()
closePanel()
return
}
}
// 面板未接管时,Enter(不带修饰键)提交,Shift+Enter 换行
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && !e.isComposing) {
e.preventDefault()
emit('submit')
}
}
function onPaste(e: ClipboardEvent) {
if (!props.pasteFiles) return
const files = Array.from(e.clipboardData?.files || [])
if (!files.length) return
e.preventDefault()
emit('paste', files)
}
defineExpose({ insertMention, focus: () => textarea.value?.focus() })
</script>
<template>
<div class="relative">
<!-- @ 候选面板:锚在输入框上方(输入框贴底,向上展开不会被视口裁掉) -->
<div
v-if="panelOpen"
class="absolute bottom-full left-0 z-30 mb-2 w-64 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg animate-scale-in"
>
<div class="border-b border-gray-100 px-3 py-1.5 text-xs font-medium text-gray-400">
引用附件
</div>
<template v-if="candidates.length">
<button
v-for="(att, i) in candidates"
:key="att.id"
class="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors duration-150"
:class="i === activeIndex ? 'bg-primary-50' : 'hover:bg-gray-50'"
@mouseenter="activeIndex = i"
@mousedown.prevent
@click="insertMention(att.name)"
>
<img
v-if="att.kind === 'image'"
:src="att.previewUrl"
class="h-7 w-7 shrink-0 rounded object-cover"
alt=""
/>
<span v-else class="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-gray-100 text-gray-500">
<Icon :name="att.kind === 'audio' ? 'musicalNote' : 'film'" size="xs" />
</span>
<span class="min-w-0 flex-1">
<span class="block text-sm text-gray-700">{{ att.name }}</span>
<span class="block truncate text-xs text-gray-400">{{ att.fileName }}</span>
</span>
</button>
</template>
<div v-else class="px-3 py-3 text-xs text-gray-400">
{{ attachments.length ? '没有匹配的附件' : '还没有附件,先在上方上传' }}
</div>
</div>
<textarea
ref="textarea"
:value="modelValue"
:placeholder="placeholder"
:rows="minRows"
:disabled="disabled"
:class="bare
? 'w-full resize-none border-0 bg-transparent px-1 py-0.5 text-sm leading-[22px] text-gray-900 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-400'
: 'input resize-none leading-[22px]'"
@input="onInput"
@keydown="onKeydown"
@paste="onPaste"
@click="syncPanel"
@blur="closePanel"
/>
</div>
</template>
@@ -6,6 +6,10 @@ import { createPoller } from '@/composables/usePolling'
import Icon from '@/components/common/Icon.vue'
import GenerationProgressCard from './GenerationProgressCard.vue'
import BaseSelect from '../../../components/common/BaseSelect.vue'
import AttachmentBar from './AttachmentBar.vue'
import PromptInput from './PromptInput.vue'
import { useAttachments } from './useAttachments'
import { compileVideoPrompt } from './attachmentParts'
interface ModelItem {
model_id: string
@@ -30,6 +34,25 @@ const prompt = ref('')
const resolution = ref('1280x720')
const duration = ref(5)
// ── 参考素材 ──
// 前端按多素材形态实现(图/视/音皆可上传、@ 列表按类型命名),但本期只有首帧参考图
// 能随请求发出:/v1/videos 的 input_reference 仅承载一张图,其余素材待后端扩协议。
const {
list: attachmentList,
accept: acceptTypes,
max: attachmentMax,
addFiles: addAttachments,
remove: removeAttachment,
} = useAttachments({ kinds: ['image', 'video', 'audio'], max: 6 })
const promptInput = ref<InstanceType<typeof PromptInput> | null>(null)
// 提交时未能随请求发出的素材名,用于结果区提示
const deferredNames = ref<string[]>([])
function insertMention(name: string) {
promptInput.value?.insertMention(name)
}
const modelOptions = computed(() => props.models.map(m => ({ value: m.model_id, label: m.model_name || m.model_id })))
// MiniMax-H3 系列使用官方分辨率档位(768P/2K,Max 追加 480P;与后端计费矩阵键一致),
@@ -98,15 +121,20 @@ async function submitTask() {
if (!prompt.value.trim() || !selectedModel.value) return
submitting.value = true
currentTask.value = null
deferredNames.value = []
stopPolling()
releaseVideo()
try {
const api = createPlaygroundApi(props.apiKey)
// 正文里的 [@图片1] 换成纯文字标签;首帧参考图取第一张图片附件
const compiled = compileVideoPrompt(prompt.value, attachmentList.value)
deferredNames.value = compiled.deferred.map(a => a.name)
// OpenAI Videos 协议:size 承载分辨率(像素串或命名档位如 768P/2K),seconds 承载时长
const body: Record<string, any> = {
model: selectedModel.value,
prompt: prompt.value,
prompt: compiled.prompt,
}
if (resolution.value) {
body.size = resolution.value
@@ -114,8 +142,13 @@ async function submitTask() {
if (duration.value) {
body.seconds = String(duration.value)
}
if (compiled.inputReference) {
// 走 JSON 引用对象形态:后端 parseVideosInputReferenceJSON 接受 data URL,
// 无需切 multipart,也不用跟 createPlaygroundApi 固定的 Content-Type 打架
body.input_reference = { image_url: compiled.inputReference }
}
const res = await api.post('/v1/videos', body, { timeout: 60_000 })
const res = await api.post('/v1/videos', body, { timeout: 120_000 })
const data = res.data
currentTask.value = {
@@ -251,9 +284,27 @@ function downloadVideo() {
<label class="input-label">模型</label>
<BaseSelect v-model="selectedModel" :options="modelOptions" />
</div>
<div>
<label class="input-label">参考素材</label>
<AttachmentBar
:attachments="attachmentList"
:accept="acceptTypes"
:max="attachmentMax"
@add="addAttachments"
@remove="removeAttachment"
@insert="insertMention"
/>
</div>
<div>
<label class="input-label">提示词</label>
<n-input v-model:value="prompt" type="textarea" :rows="4" placeholder="描述你想生成的视频..." />
<PromptInput
ref="promptInput"
v-model="prompt"
:attachments="attachmentList"
:min-rows="4"
placeholder="描述你想生成的视频,输入 @ 引用已上传的素材..."
@paste="addAttachments"
/>
</div>
<div>
<label class="input-label">分辨率</label>
@@ -299,6 +350,17 @@ function downloadVideo() {
<p class="empty-state-description">输入提示词并点击生成</p>
</div>
<!-- 本期协议带不动的素材:提交后明确告知,避免用户以为已生效 -->
<div v-if="deferredNames.length" class="mb-4 rounded-xl border border-amber-200 bg-amber-50 p-3">
<div class="flex items-center gap-2 text-amber-700">
<Icon name="exclamationTriangle" size="sm" />
<span class="text-sm font-medium">部分素材未随本次请求发送</span>
</div>
<p class="mt-1 text-xs text-amber-600">
{{ deferredNames.join('、') }} — 当前仅支持一张首帧参考图,多素材引用待后端支持
</p>
</div>
<!-- 提交中:卡片式占位 -->
<GenerationProgressCard v-if="submitting" kind="video" label="正在提交生成任务..." />
@@ -0,0 +1,120 @@
import type { Attachment, AttachmentKind } from './useAttachments'
/**
* 把「正文 + 附件」编译成各端点要的请求形态。
*
* 正文里的 [@图片1] 是位置锚点:编译时换成纯文字标签「图片1」留在句子里
* (多图场景下模型能用名字指代),同时在该位置插入对应的 content part。
* 没被引用的附件追加到末尾——上传了不打 @ 也要能发出去。
* 引用了已删除的附件时退化成纯文字,不报错打断输入。
*/
export interface ContentPart {
type: 'text' | 'image_url' | 'input_audio'
text?: string
image_url?: { url: string }
input_audio?: { data: string; format: string }
}
export interface AttachmentPreview {
name: string
kind: AttachmentKind
/** 直接用 data URL 展示:objectURL 在附件被清空时就 revoke 了,消息气泡不能依赖它 */
url: string
}
export interface CompiledChat {
/** 气泡里展示的文本(标记已换成文字标签) */
displayText: string
/** 发给 /v1/chat/completions 的 content parts */
parts: ContentPart[]
previews: AttachmentPreview[]
}
export interface CompiledVideo {
/** 发给 /v1/videos 的提示词(标记已换成文字标签) */
prompt: string
/** 首帧参考图的 data URL,无图时为空串 */
inputReference: string
/** 本期协议携带不了、未随请求发出的素材 */
deferred: Attachment[]
}
const MENTION_RE = /\[@([^\]\n]+)\]/g
/** stripMentions 把 [@图片1] 还原成纯文字标签「图片1」 */
export function stripMentions(text: string): string {
return text.replace(MENTION_RE, (_, name: string) => name)
}
/** hasMention 判断正文是否引用了某个附件 */
export function hasMention(text: string, name: string): boolean {
return text.includes(`[@${name}]`)
}
/** toMediaPart 把附件转成对应协议的 content part;不支持的类型返回 null */
function toMediaPart(att: Attachment): ContentPart | null {
if (att.kind === 'image') {
return { type: 'image_url', image_url: { url: att.dataUrl } }
}
if (att.kind === 'audio') {
// OpenAI input_audio 要的是裸 base64 + 格式名,不是完整 data URL
const base64 = att.dataUrl.slice(att.dataUrl.indexOf('base64,') + 'base64,'.length)
const format = att.mimeType.split('/')[1]?.split(';')[0] || 'mp3'
return { type: 'input_audio', input_audio: { data: base64, format } }
}
return null
}
export function compileChatParts(text: string, attachments: Attachment[]): CompiledChat {
const byName = new Map(attachments.map(a => [a.name, a]))
const referenced = new Set<number>()
const parts: ContentPart[] = []
let buf = ''
const flush = () => {
if (buf.trim()) parts.push({ type: 'text', text: buf })
buf = ''
}
let cursor = 0
MENTION_RE.lastIndex = 0
for (let m = MENTION_RE.exec(text); m !== null; m = MENTION_RE.exec(text)) {
buf += text.slice(cursor, m.index) + m[1]
cursor = m.index + m[0].length
const att = byName.get(m[1])
if (!att) continue // 失效标记:只留文字,继续累积
const part = toMediaPart(att)
if (!part) continue
flush()
parts.push(part)
referenced.add(att.id)
}
buf += text.slice(cursor)
flush()
for (const att of attachments) {
if (referenced.has(att.id)) continue
const part = toMediaPart(att)
if (part) parts.push(part)
}
return {
displayText: stripMentions(text),
parts,
previews: attachments.map(a => ({ name: a.name, kind: a.kind, url: a.dataUrl })),
}
}
export function compileVideoPrompt(text: string, attachments: Attachment[]): CompiledVideo {
// /v1/videos 的 input_reference 只能带一张图,其余素材本期发不出去
const firstImage = attachments.find(a => a.kind === 'image')
return {
prompt: stripMentions(text),
inputReference: firstImage?.dataUrl || '',
deferred: attachments.filter(a => a.id !== firstImage?.id),
}
}
@@ -0,0 +1,205 @@
import { ref, computed, onUnmounted } from 'vue'
import { toast } from '@/utils/toast'
/**
* Playground 附件管理
*
* 附件全部以 base64 data URL 内联进请求体(对话走 content parts,视频走 input_reference),
* 不落对象存储。因此体积是硬约束:后端 GoFrame server 默认 ClientMaxBodySize 为 8MB,
* base64 还要膨胀 4/3,这里按 6MB 的安全水位做单个与合计两道闸门。
*/
export type AttachmentKind = 'image' | 'audio' | 'video'
export interface Attachment {
id: number
kind: AttachmentKind
/** 引用名,如「图片1」「音频1」,即 [@图片1] 标记里的那部分 */
name: string
/** 原始文件名,用于 tooltip 展示 */
fileName: string
/** 完整 data URL(data:<mime>;base64,<载荷>),直接进请求体 */
dataUrl: string
/** 本地预览地址(objectURL),仅供 <img>/<video> 展示,用后必须 revoke */
previewUrl: string
mimeType: string
/** data URL 的字节长度,用于体积闸门 */
bytes: number
}
/** 单个附件上限:图片 5MB(超限先压缩),音视频 4MB(不压缩,超限直接拒绝) */
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_MEDIA_BYTES = 4 * 1024 * 1024
/** 所有附件 data URL 合计上限,与后端 8MB body 上限留出余量 */
export const MAX_TOTAL_BYTES = 6 * 1024 * 1024
/** 图片压缩目标:最长边与 JPEG 质量 */
const COMPRESS_MAX_EDGE = 2048
const COMPRESS_QUALITY = 0.85
const KIND_LABEL: Record<AttachmentKind, string> = {
image: '图片',
audio: '音频',
video: '视频',
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function detectKind(file: File): AttachmentKind | null {
if (file.type.startsWith('image/')) return 'image'
if (file.type.startsWith('audio/')) return 'audio'
if (file.type.startsWith('video/')) return 'video'
return null
}
function readAsDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('读取文件失败'))
reader.readAsDataURL(blob)
})
}
/**
* compressImage 把超限图片降采样后重编码为 JPEG。
* GIF 不压缩(canvas 重编码会丢掉动画),超限直接判失败。
*/
async function compressImage(file: File): Promise<Blob | null> {
if (file.type === 'image/gif') return null
const bitmapUrl = URL.createObjectURL(file)
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image()
el.onload = () => resolve(el)
el.onerror = () => reject(new Error('图片解码失败'))
el.src = bitmapUrl
})
const scale = Math.min(1, COMPRESS_MAX_EDGE / Math.max(img.width, img.height))
const canvas = document.createElement('canvas')
canvas.width = Math.round(img.width * scale)
canvas.height = Math.round(img.height * scale)
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
return await new Promise<Blob | null>(resolve => {
canvas.toBlob(blob => resolve(blob), 'image/jpeg', COMPRESS_QUALITY)
})
} catch {
return null
} finally {
URL.revokeObjectURL(bitmapUrl)
}
}
export interface UseAttachmentsOptions {
/**
* 允许的附件类型,未列出的类型会被拒绝。
* 传 getter 可跟随模型能力动态收放(切到非 vision 模型后就不该再收图片)。
*/
kinds: AttachmentKind[] | (() => AttachmentKind[])
/** 附件数量上限 */
max?: number
}
export function useAttachments(options: UseAttachmentsOptions) {
const list = ref<Attachment[]>([])
const max = options.max ?? 9
let nextId = 1
// 各类型独立递增。删除后不重排号——正文里可能已经写了 [@图片2],
// 重排会让这些标记悄悄指向另一张图,比留个号码空档危险得多。
const counters: Record<AttachmentKind, number> = { image: 0, audio: 0, video: 0 }
const kinds = computed(() =>
typeof options.kinds === 'function' ? options.kinds() : options.kinds,
)
const totalBytes = computed(() => list.value.reduce((sum, a) => sum + a.bytes, 0))
const accept = computed(() => kinds.value.map(k => `${k}/*`).join(','))
async function addFiles(files: File[] | FileList) {
const incoming = Array.from(files)
for (const file of incoming) {
if (list.value.length >= max) {
toast.warning(`最多上传 ${max} 个附件`)
return
}
await addOne(file)
}
}
async function addOne(file: File) {
const kind = detectKind(file)
if (!kind || !kinds.value.includes(kind)) {
toast.error(`不支持的文件类型:${file.name}`)
return
}
let source: Blob = file
let mimeType = file.type
if (kind === 'image' && file.size > MAX_IMAGE_BYTES) {
const compressed = await compressImage(file)
if (!compressed || compressed.size > MAX_IMAGE_BYTES) {
toast.error(`${file.name} 超过 ${formatBytes(MAX_IMAGE_BYTES)},压缩后仍过大`)
return
}
source = compressed
mimeType = compressed.type
} else if (kind !== 'image' && file.size > MAX_MEDIA_BYTES) {
toast.error(`${file.name} 超过 ${formatBytes(MAX_MEDIA_BYTES)}`)
return
}
let dataUrl: string
try {
dataUrl = await readAsDataUrl(source)
} catch {
toast.error(`${file.name} 读取失败`)
return
}
const bytes = dataUrl.length
if (totalBytes.value + bytes > MAX_TOTAL_BYTES) {
toast.error(`附件合计超过 ${formatBytes(MAX_TOTAL_BYTES)},请先删除部分附件`)
return
}
counters[kind] += 1
list.value.push({
id: nextId++,
kind,
name: `${KIND_LABEL[kind]}${counters[kind]}`,
fileName: file.name,
dataUrl,
previewUrl: URL.createObjectURL(source),
mimeType,
bytes,
})
}
function remove(id: number) {
const idx = list.value.findIndex(a => a.id === id)
if (idx === -1) return
URL.revokeObjectURL(list.value[idx].previewUrl)
list.value.splice(idx, 1)
}
function clear() {
list.value.forEach(a => URL.revokeObjectURL(a.previewUrl))
list.value = []
}
// Playground 的 tab 由 KeepAlive 缓存,组件不会随切换销毁;
// 但路由离开时仍会卸载,此处兜底释放 objectURL,避免泄漏。
onUnmounted(clear)
return { list, totalBytes, accept, max, addFiles, remove, clear }
}