diff --git a/scripts/verify-image-url-parts.mjs b/scripts/verify-image-url-parts.mjs new file mode 100644 index 0000000..7d6876e --- /dev/null +++ b/scripts/verify-image-url-parts.mjs @@ -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) 内的 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) 内的 url 也被扫到(无害)', () => { + const parts = extractImageUrlParts('看这张 ![alt text](https://cdn.test/cat.png) 可爱吗') + 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') +} diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue index 2d19240..3844086 100644 --- a/src/components/ai/ChatInput.vue +++ b/src/components/ai/ChatInput.vue @@ -1,16 +1,18 @@