feat(ui):优化在线体验功能
This commit is contained in:
1 parent
f3e930d027
commit
4c212aeb04
14 files changed
+1703
-139
No files matched your search
@@ -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 记录 |
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 级别应返回空")
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user