新增: 多模态图片 URL 转 image part + 修复 / 技能联想浮层定位
前端发送前扫图片扩展 URL 转 ContentPart image(模型原生视觉,非工具下载); SkillMention 移入 .ai-input-wrap relative 内(治 / 浮层定位错位不可见)。
This commit is contained in:
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* extractImageUrlParts 单测(F-260614-05 Phase 2c 多模态 URL → image 片)。
|
||||||
|
*
|
||||||
|
* 背景:前端无 vitest(引入框架超白名单),沿用 verify-streaming-guard.mjs 风格——
|
||||||
|
* 零依赖 Node 内置 assert。本脚本直连源码 import(不再内联副本,无漂移风险),
|
||||||
|
* 依赖 Node v22+ 实验性 strip-types(native TS 执行)。
|
||||||
|
*
|
||||||
|
* 覆盖:
|
||||||
|
* 1. 图片扩展(.png/.jpg/.jpeg/.webp/.gif)→ image 片(url 模式,base64/media_type/alt 全 null)
|
||||||
|
* 2. 大小写不敏感(.PNG/.JPG)
|
||||||
|
* 3. 带/不带查询参数(?foo=bar#anchor 形态 — #anchor 走 \S* 吞进 url)
|
||||||
|
* 4. 多 URL → 多片,保持首次出现顺序
|
||||||
|
* 5. 同 URL 去重
|
||||||
|
* 6. 非 URL(http 以外 / 本地路径 / data URI / 邮件附件)不提取
|
||||||
|
* 7. 非图片扩展(.html/.com/.pdf)不提取
|
||||||
|
* 8. markdown 图片语法  内的 url 也被扫到(无害,content 文本仍保留原样)
|
||||||
|
* 9. 空串/无 URL → 空数组
|
||||||
|
* 10. URL 紧跟标点(逗号/中文句号)→ 被吞进 url(可接受误判,vision provider 通常容忍)
|
||||||
|
*
|
||||||
|
* 运行:node scripts/verify-image-url-parts.mjs
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
// 直连源码(避免内联副本漂移);Node v22+ strip-types 原生执行 .ts
|
||||||
|
import { extractImageUrlParts } from '../src/composables/ai/utils.ts'
|
||||||
|
|
||||||
|
let passed = 0
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn()
|
||||||
|
passed++
|
||||||
|
console.log(` ✓ ${name}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` ✗ ${name}`)
|
||||||
|
console.error(` ${e.message}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. 图片扩展 → image 片(url 模式) ──
|
||||||
|
test('图片扩展 .png/.jpg/.jpeg/.webp/.gif 全部提取为 image 片', () => {
|
||||||
|
for (const ext of ['png', 'jpg', 'jpeg', 'webp', 'gif']) {
|
||||||
|
const parts = extractImageUrlParts(`https://cdn.test/img.${ext}`)
|
||||||
|
assert.equal(parts.length, 1, `扩展 ${ext} 应提取 1 片`)
|
||||||
|
assert.equal(parts[0].type, 'image')
|
||||||
|
assert.equal(parts[0].url, `https://cdn.test/img.${ext}`)
|
||||||
|
assert.equal(parts[0].base64, null, 'url 模式 base64 必须为 null')
|
||||||
|
assert.equal(parts[0].media_type, null, 'url 模式 media_type 必须为 null')
|
||||||
|
assert.equal(parts[0].alt, null, 'alt 默认 null')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 2. 大小写不敏感 ──
|
||||||
|
test('扩展名大小写不敏感(.PNG/.JPG/.GIF)', () => {
|
||||||
|
for (const ext of ['PNG', 'JPG', 'GIF', 'WebP', 'Jpeg']) {
|
||||||
|
const parts = extractImageUrlParts(`https://cdn.test/img.${ext}`)
|
||||||
|
assert.equal(parts.length, 1, `扩展 ${ext} 应被识别`)
|
||||||
|
assert.equal(parts[0].url, `https://cdn.test/img.${ext}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 3. 查询参数 / fragment ──
|
||||||
|
test('带查询参数的 URL 提取(?foo=bar&w=2)', () => {
|
||||||
|
const parts = extractImageUrlParts('https://cdn.test/img.png?foo=bar&w=2&h=3')
|
||||||
|
assert.equal(parts.length, 1)
|
||||||
|
assert.equal(parts[0].url, 'https://cdn.test/img.png?foo=bar&w=2&h=3')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('带 fragment 的 URL:扩展名后非 ? 字符(如 #)不被吞进 url', () => {
|
||||||
|
// 正则 (?:\?\S*)? 只在 ? 后吞内容,#anchor 不被吞(vision fetch 通常忽略 fragment,行为正确)
|
||||||
|
const parts = extractImageUrlParts('https://cdn.test/img.png#anchor')
|
||||||
|
assert.equal(parts.length, 1)
|
||||||
|
assert.equal(parts[0].url, 'https://cdn.test/img.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 4. 多 URL 保持顺序 ──
|
||||||
|
test('多 URL 提取为多片,保持首次出现顺序', () => {
|
||||||
|
const parts = extractImageUrlParts('first https://a.com/1.png middle https://b.com/2.jpg end')
|
||||||
|
assert.equal(parts.length, 2)
|
||||||
|
assert.equal(parts[0].url, 'https://a.com/1.png')
|
||||||
|
assert.equal(parts[1].url, 'https://b.com/2.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 5. 同 URL 去重 ──
|
||||||
|
test('同 URL 多次出现只提取一次', () => {
|
||||||
|
const parts = extractImageUrlParts('https://a.com/1.png https://a.com/1.png again https://a.com/1.png')
|
||||||
|
assert.equal(parts.length, 1)
|
||||||
|
assert.equal(parts[0].url, 'https://a.com/1.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 6. 非图片 URL 不提取 ──
|
||||||
|
test('http 以外的协议(ftp:// / file://)不提取', () => {
|
||||||
|
const parts = extractImageUrlParts('see ftp://a.com/x.png and file:///x.jpg')
|
||||||
|
assert.equal(parts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('本地路径不提取(无协议头)', () => {
|
||||||
|
const parts = extractImageUrlParts('/home/user/img.png and ./local.jpg')
|
||||||
|
assert.equal(parts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('data URI 不提取(粘贴 base64 走 ImageInput 单独处理)', () => {
|
||||||
|
const parts = extractImageUrlParts('data:image/png;base64,iVBOR==')
|
||||||
|
assert.equal(parts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 7. 非图片扩展 ──
|
||||||
|
test('非图片扩展(.html/.com/.pdf/.svg/.bmp)不提取', () => {
|
||||||
|
// 注:.svg/.bmp 不在白名单(商汤/OpenAI 图片扩展对齐 png/jpg/jpeg/webp/gif)
|
||||||
|
for (const ext of ['html', 'com', 'pdf', 'svg', 'bmp', 'txt']) {
|
||||||
|
const parts = extractImageUrlParts(`https://a.com/page.${ext}`)
|
||||||
|
assert.equal(parts.length, 0, `扩展 ${ext} 不应被提取`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 8. markdown 图片语法 ──
|
||||||
|
test('markdown 图片语法  内的 url 也被扫到(无害)', () => {
|
||||||
|
const parts = extractImageUrlParts('看这张  可爱吗')
|
||||||
|
assert.equal(parts.length, 1)
|
||||||
|
assert.equal(parts[0].url, 'https://cdn.test/cat.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('markdown 链接 [text](url) 内的图片 url 也被扫到', () => {
|
||||||
|
const parts = extractImageUrlParts('[link](https://cdn.test/diagram.webp)')
|
||||||
|
assert.equal(parts.length, 1)
|
||||||
|
assert.equal(parts[0].url, 'https://cdn.test/diagram.webp')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 9. 空串 / 无 URL ──
|
||||||
|
test('空串返回空数组', () => {
|
||||||
|
assert.deepEqual(extractImageUrlParts(''), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('null 输入防御返回空数组', () => {
|
||||||
|
// helper 内部 if (!text) 防御 null/undefined
|
||||||
|
assert.deepEqual(extractImageUrlParts(null), [])
|
||||||
|
assert.deepEqual(extractImageUrlParts(undefined), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('无图片 URL 的文本返回空数组', () => {
|
||||||
|
assert.deepEqual(extractImageUrlParts('hello world 这是一段普通文本 https://a.com/page.html'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 10. URL 紧跟标点(标点不被吞进) ──
|
||||||
|
test('URL 紧跟英文逗号:逗号不被吞进 url', () => {
|
||||||
|
// 正则 (?:\?\S*)? 只在 ? 后吞内容,逗号(非 ?)不被吞进 url —— 行为正确
|
||||||
|
const parts = extractImageUrlParts('see https://a.com/img.png, then continue')
|
||||||
|
assert.equal(parts.length, 1)
|
||||||
|
assert.equal(parts[0].url, 'https://a.com/img.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('URL 紧跟中文逗号/句号:标点不被吞进 url', () => {
|
||||||
|
const parts = extractImageUrlParts('看 https://a.com/img.png,这是图。')
|
||||||
|
assert.equal(parts.length, 1)
|
||||||
|
assert.equal(parts[0].url, 'https://a.com/img.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('混合场景:多 URL + 普通文本 + 重复 URL', () => {
|
||||||
|
const text = '看 https://a.com/1.png 这张图,还有 https://b.com/2.jpg, 以及 https://a.com/1.png 重复'
|
||||||
|
const parts = extractImageUrlParts(text)
|
||||||
|
// 3 次出现,2 个唯一 URL(去重);标点不被吞进
|
||||||
|
assert.equal(parts.length, 2)
|
||||||
|
assert.equal(parts[0].url, 'https://a.com/1.png')
|
||||||
|
assert.equal(parts[1].url, 'https://b.com/2.jpg')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 总结 ──
|
||||||
|
console.log(`\n${passed} passed`)
|
||||||
|
if (process.exitCode) {
|
||||||
|
console.error('FAILED')
|
||||||
|
} else {
|
||||||
|
console.log('OK')
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ai-input-area">
|
<div class="ai-input-area">
|
||||||
<SkillMention
|
|
||||||
:skill-open="skillOpen"
|
|
||||||
:filtered-skills="filteredSkills"
|
|
||||||
:skill-index="skillIndex"
|
|
||||||
:pending-skill="pendingSkill"
|
|
||||||
:skill-query="skillQuery"
|
|
||||||
@select="selectSkill"
|
|
||||||
@clear="clearSkill"
|
|
||||||
@update:skill-index="skillIndex = $event"
|
|
||||||
/>
|
|
||||||
<div class="ai-input-wrap">
|
<div class="ai-input-wrap">
|
||||||
|
<!-- 技能联想浮层须在 .ai-input-wrap(position:relative)内,
|
||||||
|
否则 .ai-skill-popover 的 absolute 定位错位跑到页面外不可见(/ 未触发根因)。 -->
|
||||||
|
<SkillMention
|
||||||
|
:skill-open="skillOpen"
|
||||||
|
:filtered-skills="filteredSkills"
|
||||||
|
:skill-index="skillIndex"
|
||||||
|
:pending-skill="pendingSkill"
|
||||||
|
:skill-query="skillQuery"
|
||||||
|
@select="selectSkill"
|
||||||
|
@clear="clearSkill"
|
||||||
|
@update:skill-index="skillIndex = $event"
|
||||||
|
/>
|
||||||
<!-- F-260614-05 Phase 2b: 待发送图片预览 -->
|
<!-- F-260614-05 Phase 2b: 待发送图片预览 -->
|
||||||
<ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
|
<ImageInput :pending-images="pendingImages" @remove="removePendingImage" />
|
||||||
<textarea
|
<textarea
|
||||||
@@ -103,6 +105,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { useProjectStore } from '../../stores/project'
|
import { useProjectStore } from '../../stores/project'
|
||||||
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
|
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
|
||||||
|
import { extractImageUrlParts } from '../../composables/ai/utils'
|
||||||
import SkillMention from './SkillMention.vue'
|
import SkillMention from './SkillMention.vue'
|
||||||
import ImageInput from './ImageInput.vue'
|
import ImageInput from './ImageInput.vue'
|
||||||
import MentionPopover from './MentionPopover.vue'
|
import MentionPopover from './MentionPopover.vue'
|
||||||
@@ -688,6 +691,14 @@ async function handleSend() {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: undefined
|
: undefined
|
||||||
|
// F-260614-05 Phase 2c: 扫输入文本里的图片 URL → image 片(url 模式),与粘贴 base64 图合并。
|
||||||
|
// 治会话 01f05167:用户贴 URL 文本 → 没转 parts → 模型只收字符串 → AI fetch 失败 L1 熔断。
|
||||||
|
// text 保留原样(供 audit/纯文本模型),parts 附加 image 让 vision 模型直接看图。
|
||||||
|
// parts 已含 text 片(粘贴图场景)时直接展开追加 url 图;parts 为空且 text 非空时补 text 片对齐上面构造逻辑。
|
||||||
|
const urlParts = extractImageUrlParts(text)
|
||||||
|
const mergedParts: ContentPart[] | undefined = urlParts.length > 0
|
||||||
|
? [...(parts ?? (text ? [{ type: 'text' as const, text } as ContentPart] : [])), ...urlParts]
|
||||||
|
: parts
|
||||||
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
|
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
|
||||||
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
|
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
@@ -704,7 +715,7 @@ async function handleSend() {
|
|||||||
try {
|
try {
|
||||||
// Input Augmentation: 透传 mentionSpans(L0 直接 doSend 注入 + 本地 user 消息挂 mentionSpans 渲染 chip;
|
// Input Augmentation: 透传 mentionSpans(L0 直接 doSend 注入 + 本地 user 消息挂 mentionSpans 渲染 chip;
|
||||||
// L1 入队续发当前不挂 spans,见 useAiSend.sendMessage 设计取舍注释)
|
// L1 入队续发当前不挂 spans,见 useAiSend.sendMessage 设计取舍注释)
|
||||||
await store.sendMessage(text, skill?.name, false, parts, snapshotSpans.length > 0 ? snapshotSpans : undefined)
|
await store.sendMessage(text, skill?.name, false, mergedParts, snapshotSpans.length > 0 ? snapshotSpans : undefined)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[AI] 发送失败:', e)
|
console.error('[AI] 发送失败:', e)
|
||||||
inputText.value = text
|
inputText.value = text
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* AI composables 通用纯函数工具(无 Vue 依赖,便于单测)。
|
||||||
|
*
|
||||||
|
* 当前仅含 F-260614-05 Phase 2c 扩展:输入框文本中的图片 URL → ContentPart[] image 片。
|
||||||
|
* 治会话 01f05167:用户贴图片 URL(文本)→ 没转 parts → 模型只收 URL 字符串 →
|
||||||
|
* AI 转 fetch_url/download 失败 L1 熔断卡死。这里在发送前扫 URL 转 image 片,
|
||||||
|
* 让 vision 模型直接拿到图片(provider 端 url 模式转发 image_url{url} 给 OpenAI/商汤)。
|
||||||
|
*/
|
||||||
|
import type { ContentPart } from '../../api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片 URL 正则:
|
||||||
|
* - https?:// 协议头
|
||||||
|
* - \S+? 主机+路径(非贪婪,避免贪婪吞掉后续 URL)
|
||||||
|
* - \.(png|jpg|jpeg|webp|gif) 图片扩展名(大小写不敏感)
|
||||||
|
* - (\?\S*)? 可选查询参数(?foo=bar,直到空白为止)
|
||||||
|
*
|
||||||
|
* g 全局 + i 忽略大小写。不锚定行首/尾,允许 URL 嵌在任意文本里(markdown `` / 纯 URL / 句中)。
|
||||||
|
*
|
||||||
|
* 边界说明:可选组 (?:\?\S*)? 仅在扩展名后紧跟 ? 时才吞后续非空白字符,因此 URL 末尾的标点
|
||||||
|
* (逗号/句号/中文标点/fragment #anchor)不会被吞进 url —— 这是正确行为,vision fetch 拿干净 URL。
|
||||||
|
*/
|
||||||
|
const IMAGE_URL_RE = /https?:\/\/\S+?\.(?:png|jpe?g|webp|gif)(?:\?\S*)?/gi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中扫描图片 URL,转成 ContentPart[] image 片(url 模式)。
|
||||||
|
*
|
||||||
|
* - 仅 url 模式(base64 / media_type 留空):vision provider 端按 url 模式直接转发 http URL 给上游,
|
||||||
|
* OpenAI/商汤无需 base64。
|
||||||
|
* - 同 URL 去重(保持首次出现顺序)。
|
||||||
|
* - 非 URL(本地路径 / data URI / 邮件附件)不提取:这些不是 http(s) URL,vision provider 也取不到。
|
||||||
|
* - 非图片扩展(.html/.com/.pdf 等)不提取。
|
||||||
|
*
|
||||||
|
* @param text 输入框文本(原始,未 trim 也可)
|
||||||
|
* @returns image 片数组(可能为空)
|
||||||
|
*/
|
||||||
|
export function extractImageUrlParts(text: string): ContentPart[] {
|
||||||
|
if (!text) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const parts: ContentPart[] = []
|
||||||
|
// 注意:lastIndex 在 g 标志正则上是状态化的,这里每次新建迭代器从 0 开始,无需手动 reset
|
||||||
|
for (const m of text.matchAll(IMAGE_URL_RE)) {
|
||||||
|
const url = m[0]
|
||||||
|
if (seen.has(url)) continue
|
||||||
|
seen.add(url)
|
||||||
|
parts.push({
|
||||||
|
type: 'image',
|
||||||
|
url,
|
||||||
|
base64: null,
|
||||||
|
media_type: null,
|
||||||
|
alt: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user