9 changed files with 460 additions and 355 deletions
-
28frontend/src/api/chat.ts
-
15frontend/src/components/MessageSources.vue
-
147frontend/src/sdk-test/SdkTestPanel.vue
-
18frontend/src/types/sse.ts
-
3frontend/src/utils/chatAdapter.ts
-
275frontend/src/utils/sse.ts
-
131frontend/src/views/ChatPanel.vue
-
34frontend/src/views/PipelineFlow.vue
-
164frontend/tests/chat-protocol.test.mjs
@ -1,206 +1,139 @@ |
|||
/** |
|||
* SSE 流式读取工具 —— 从 utils.js 原封不动搬移 |
|||
* |
|||
* 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口。 |
|||
* 此文件使用原生 fetch + ReadableStream API,零框架依赖。 |
|||
*/ |
|||
import type { SSECallbacks } from '@/types/sse' |
|||
/** Shared SSE reader for plain text, OpenAI chunks and tool events. */ |
|||
import type { SSECallbacks, SourceReference } from '@/types/sse' |
|||
import { getToken } from '@/utils/token' |
|||
|
|||
/** 构建带认证的请求头 */ |
|||
/** Preserve explicit SDK authorization instead of replacing it with the admin token. */ |
|||
function authHeaders(extra?: Record<string, string>): Record<string, string> { |
|||
const token = getToken() |
|||
const base: Record<string, string> = extra ? { ...extra } : {} |
|||
// 仅当调用方未显式提供 Authorization 时才注入管理后台 token,避免覆盖调用方传入的鉴权头(如测试面板的 SDK Token)
|
|||
if (token && !base['Authorization']) base['Authorization'] = `Bearer ${token}` |
|||
return base |
|||
const headers = { ...extra } |
|||
if (token && !headers['Authorization']) headers['Authorization'] = `Bearer ${token}` |
|||
return headers |
|||
} |
|||
|
|||
/** 尝试从 OpenAI Chat Completions chunk 中提取 delta.content;返回 undefined 表示非 OpenAI 格式(回退纯文本) */ |
|||
function extractOpenAIDelta(text: string): string | undefined { |
|||
let obj: any |
|||
try { obj = JSON.parse(text) } catch { return undefined } |
|||
// OpenAI 错误 chunk({"error":{"message":...}}):提取错误信息作为内容,避免原始 JSON 泄漏到对话框
|
|||
if (obj && obj.error) { |
|||
const msg = obj.error.message || obj.error.type |
|||
return typeof msg === 'string' && msg ? msg : '服务异常' |
|||
function isSourceReference(value: unknown): value is SourceReference { |
|||
if (!value || typeof value !== 'object') return false |
|||
const nullableString = (v: unknown) => v === null || typeof v === 'string' |
|||
const nullableNumber = (v: unknown) => v === null || typeof v === 'number' |
|||
return 'documentId' in value && nullableString(value.documentId) |
|||
&& 'title' in value && nullableString(value.title) |
|||
&& 'sourceName' in value && nullableString(value.sourceName) |
|||
&& 'chunkIndex' in value && nullableNumber(value.chunkIndex) |
|||
&& 'score' in value && nullableNumber(value.score) |
|||
&& 'snippet' in value && nullableString(value.snippet) |
|||
} |
|||
|
|||
/** Returns false only for legacy plain-text payloads. Metadata never enters message text. */ |
|||
function dispatchOpenAI(text: string, handlers: SSECallbacks): boolean { |
|||
let value: unknown |
|||
try { value = JSON.parse(text) } catch { return false } |
|||
if (!value || typeof value !== 'object') return false |
|||
if ('error' in value && value.error && typeof value.error === 'object') { |
|||
const error = value.error |
|||
const message = 'message' in error ? error.message : 'type' in error ? error.type : undefined |
|||
handlers.onMessage?.(typeof message === 'string' && message ? message : '服务异常') |
|||
return true |
|||
} |
|||
if (obj && Array.isArray(obj.choices)) { |
|||
const delta = obj.choices[0]?.delta |
|||
if (delta && typeof delta.content === 'string') return delta.content |
|||
return '' // OpenAI 形状但无 content(role/finish_reason 空 chunk)→ 跳过
|
|||
if (!('choices' in value) || !Array.isArray(value.choices)) return false |
|||
if ('sources' in value && Array.isArray(value.sources)) { |
|||
if (!value.sources.every(isSourceReference)) throw new Error('无效的引用来源数据') |
|||
handlers.onSources?.(value.sources) |
|||
} |
|||
return undefined // 是 JSON 但非 OpenAI 形状 → 回退纯文本
|
|||
const content = value.choices[0]?.delta?.content |
|||
if (typeof content === 'string' && content) handlers.onMessage?.(content) |
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 通用 SSE 流式读取 —— 统一处理 Flux<String> / ServerSentEvent / SseEmitter 三种 SSE 接口 |
|||
* |
|||
* @param url 请求地址 |
|||
* @param onChunk 每收到一段文本的回调 |
|||
* @param onDone 流结束的回调 |
|||
* @param headers 额外请求头 |
|||
* @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源) |
|||
*/ |
|||
export async function readSSEStream( |
|||
/** Text-only callers share the same framing, completion and cleanup semantics. */ |
|||
export function readSSEStream( |
|||
url: string, |
|||
onChunk: (text: string) => void, |
|||
onDone?: () => void, |
|||
headers?: Record<string, string>, |
|||
signal?: AbortSignal |
|||
signal?: AbortSignal, |
|||
): Promise<void> { |
|||
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' }) |
|||
if (!res.ok) throw new Error('HTTP ' + res.status) |
|||
const reader = res.body!.getReader() |
|||
const decoder = new TextDecoder() |
|||
let buffer = '' |
|||
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接,事件间用空行分隔
|
|||
let eventDataLines: string[] = [] |
|||
let currentEvent = 'message' |
|||
|
|||
const flushEvent = () => { |
|||
if (eventDataLines.length === 0) return |
|||
const text = eventDataLines.join('\n') |
|||
eventDataLines = [] |
|||
const ev = currentEvent |
|||
currentEvent = 'message' |
|||
if (text === '[DONE]') return |
|||
// 跳过 status / faq 等系统事件,不显示在对话框中
|
|||
if (ev === 'status') return |
|||
// OpenAI Chat Completions 格式:提取 delta.content;无 content 的空 chunk 跳过
|
|||
const extracted = extractOpenAIDelta(text) |
|||
if (extracted !== undefined) { |
|||
if (extracted) onChunk(extracted) |
|||
return |
|||
} |
|||
// 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件)
|
|||
onChunk(text || '\n') |
|||
} |
|||
|
|||
while (true) { |
|||
const { done, value } = await reader.read() |
|||
if (done) break |
|||
buffer += decoder.decode(value, { stream: true }) |
|||
const lines = buffer.split('\n') |
|||
buffer = lines.pop() || '' |
|||
for (let line of lines) { |
|||
// 兼容 \r\n 行结束符
|
|||
if (line.endsWith('\r')) line = line.slice(0, -1) |
|||
if (line === '') { |
|||
// 空行 = SSE 事件边界
|
|||
flushEvent() |
|||
} else if (line.startsWith('event:')) { |
|||
// 记录事件类型,用于 flushEvent 时过滤系统事件
|
|||
currentEvent = line.slice(6).trim() |
|||
} else if (line.startsWith('data:')) { |
|||
// 累积 data 字段;仅剥离 data: 后的一个可选空格,保留 markdown 列表缩进
|
|||
let data = line.slice(5) |
|||
if (data.startsWith(' ')) data = data.slice(1) |
|||
eventDataLines.push(data) |
|||
} else if (!line.startsWith(':')) { |
|||
// Flux<String> 模式(非标准 SSE),先把已累积的 SSE 事件 flush 再处理
|
|||
flushEvent() |
|||
if (line.trim()) onChunk(line) |
|||
} |
|||
} |
|||
} |
|||
// 流结束,flush 末尾未以空行收尾的事件
|
|||
flushEvent() |
|||
if (onDone) onDone() |
|||
return readSSEStreamWithEvents(url, { onMessage: onChunk, onDone }, headers, signal) |
|||
} |
|||
|
|||
/** |
|||
* 增强版 SSE 流式读取(支持事件类型分发) |
|||
* 解析 SSE 标准的 event: 字段,将不同类型事件分发到对应回调。 |
|||
* |
|||
* @param url 请求地址 |
|||
* @param handlers 回调对象: |
|||
* - onMessage(chunk): 普通文本内容(event: message 或无 event 的 data) |
|||
* - onToolCallStart(data): 工具调用开始(event: tool_call_start) |
|||
* - onToolCallResult(data): 工具调用结果(event: tool_call_result) |
|||
* - onError(data): 错误事件(event: error) |
|||
* - onDone(): 流结束 |
|||
* @param headers 额外请求头 |
|||
* @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源) |
|||
*/ |
|||
/** Read complete SSE events; [DONE] terminates immediately without waiting for network EOF. */ |
|||
export async function readSSEStreamWithEvents( |
|||
url: string, |
|||
handlers: SSECallbacks, |
|||
headers?: Record<string, string>, |
|||
signal?: AbortSignal |
|||
signal?: AbortSignal, |
|||
): Promise<void> { |
|||
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers |
|||
const res = await fetch(url, { headers: authHeaders(headers), signal, credentials: 'include' }) |
|||
if (!res.ok) throw new Error('HTTP ' + res.status) |
|||
const reader = res.body!.getReader() |
|||
if (!res.ok) throw new Error((await res.text()) || 'HTTP ' + res.status) |
|||
if (!res.body) throw new Error('响应流为空') |
|||
const reader = res.body.getReader() |
|||
const decoder = new TextDecoder() |
|||
let buffer = '' |
|||
let currentEvent = 'message' |
|||
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接
|
|||
let eventDataLines: string[] = [] |
|||
let event = 'message' |
|||
let data: string[] = [] |
|||
let completed = false |
|||
let eof = false |
|||
|
|||
const flushEvent = () => { |
|||
if (eventDataLines.length === 0) return |
|||
const ev = currentEvent |
|||
currentEvent = 'message' |
|||
const raw = eventDataLines.join('\n') |
|||
eventDataLines = [] |
|||
if (raw === '[DONE]') return |
|||
// 空事件视为换行符(仅 message 事件);JSON 事件(工具调用等)空内容不应出现
|
|||
const text = raw || '\n' |
|||
|
|||
switch (ev) { |
|||
case 'tool_call_start': |
|||
if (onToolCallStart) onToolCallStart(JSON.parse(text)) |
|||
break |
|||
case 'tool_call_result': |
|||
if (onToolCallResult) onToolCallResult(JSON.parse(text)) |
|||
break |
|||
case 'error': |
|||
if (onError) onError(JSON.parse(text)) |
|||
break |
|||
case 'status': |
|||
// 系统状态事件(generating / faq_hit 等),不显示在对话框中
|
|||
break |
|||
case 'message': |
|||
default: { |
|||
// OpenAI Chat Completions 格式:提取 delta.content;无 content 的空 chunk 跳过
|
|||
const extracted = extractOpenAIDelta(text) |
|||
if (extracted !== undefined) { |
|||
if (extracted && onMessage) onMessage(extracted) |
|||
} else if (onMessage) { |
|||
onMessage(text) |
|||
} |
|||
break |
|||
} |
|||
const currentEvent = event |
|||
event = 'message' |
|||
if (!data.length) return |
|||
const raw = data.join('\n') |
|||
data = [] |
|||
if (raw === '[DONE]') { |
|||
completed = true |
|||
return |
|||
} |
|||
switch (currentEvent) { |
|||
case 'status': return |
|||
case 'tool_call_start': handlers.onToolCallStart?.(JSON.parse(raw)); return |
|||
case 'tool_call_result': handlers.onToolCallResult?.(JSON.parse(raw)); return |
|||
case 'error': handlers.onError?.(JSON.parse(raw)); return |
|||
default: |
|||
if (!dispatchOpenAI(raw, handlers)) handlers.onMessage?.(raw || '\n') |
|||
} |
|||
} |
|||
|
|||
while (true) { |
|||
const { done, value } = await reader.read() |
|||
if (done) break |
|||
buffer += decoder.decode(value, { stream: true }) |
|||
const lines = buffer.split('\n') |
|||
buffer = lines.pop() || '' |
|||
const consumeLine = (raw: string) => { |
|||
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw |
|||
if (!line) { |
|||
flushEvent() |
|||
} else if (line.startsWith('event:')) { |
|||
event = line.slice(6).trim() |
|||
} else if (line === 'data' || line.startsWith('data:')) { |
|||
const value = line === 'data' ? '' : line.slice(5) |
|||
data.push(value.startsWith(' ') ? value.slice(1) : value) |
|||
} else if (!line.startsWith(':') && !line.startsWith('id:') && !line.startsWith('retry:')) { |
|||
// Legacy Flux<String> bodies may contain unframed lines.
|
|||
flushEvent() |
|||
if (!completed && line.trim()) handlers.onMessage?.(line) |
|||
} |
|||
} |
|||
|
|||
for (let line of lines) { |
|||
if (line.endsWith('\r')) line = line.slice(0, -1) |
|||
if (line === '') { |
|||
// 空行 = SSE 事件边界
|
|||
flushEvent() |
|||
} else if (line.startsWith('event:')) { |
|||
currentEvent = line.slice(6).trim() |
|||
} else if (line.startsWith('data:')) { |
|||
let data = line.slice(5) |
|||
if (data.startsWith(' ')) data = data.slice(1) |
|||
eventDataLines.push(data) |
|||
} else if (!line.startsWith(':')) { |
|||
// Flux<String> 模式(非标准 SSE)
|
|||
flushEvent() |
|||
if (line.trim() && onMessage) onMessage(line) |
|||
try { |
|||
while (!completed) { |
|||
signal?.throwIfAborted() |
|||
const result = await reader.read() |
|||
signal?.throwIfAborted() |
|||
eof = result.done |
|||
buffer += eof ? decoder.decode() : decoder.decode(result.value, { stream: true }) |
|||
let start = 0 |
|||
let end: number |
|||
while (!completed && (end = buffer.indexOf('\n', start)) !== -1) { |
|||
consumeLine(buffer.slice(start, end)) |
|||
start = end + 1 |
|||
} |
|||
buffer = buffer.slice(start) |
|||
if (eof) { |
|||
if (!completed && buffer) consumeLine(buffer) |
|||
if (!completed) flushEvent() |
|||
break |
|||
} |
|||
} |
|||
handlers.onDone?.() |
|||
} finally { |
|||
// Cancellation may itself reject or stall; it must not delay DONE or mask the original error.
|
|||
if (!eof) { |
|||
try { void reader.cancel().catch(() => {}) } catch { /* Preserve the stream outcome. */ } |
|||
} |
|||
try { reader.releaseLock() } catch { /* Preserve the stream outcome. */ } |
|||
} |
|||
flushEvent() |
|||
if (onDone) onDone() |
|||
} |
|||
@ -0,0 +1,164 @@ |
|||
import assert from 'node:assert/strict' |
|||
import { afterEach, beforeEach, test } from 'node:test' |
|||
import { fileURLToPath } from 'node:url' |
|||
import { build } from 'esbuild' |
|||
|
|||
// Exercise the production TypeScript with Vite's existing esbuild dependency, no test framework. |
|||
const root = fileURLToPath(new URL('../', import.meta.url)) |
|||
async function loadModule(entry) { |
|||
const result = await build({ |
|||
entryPoints: [root + entry], bundle: true, write: false, format: 'esm', platform: 'node', |
|||
alias: { '@': root + 'src' }, |
|||
}) |
|||
return import('data:text/javascript;base64,' + Buffer.from(result.outputFiles[0].text).toString('base64')) |
|||
} |
|||
const { readSSEStream, readSSEStreamWithEvents } = await loadModule('src/utils/sse.ts') |
|||
const { chatSync, fetchChatResult } = await loadModule('src/api/chat.ts') |
|||
const originalFetch = globalThis.fetch |
|||
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') |
|||
|
|||
beforeEach(() => { |
|||
Object.defineProperty(globalThis, 'localStorage', { |
|||
configurable: true, value: { getItem: () => 'admin-token' }, |
|||
}) |
|||
}) |
|||
afterEach(() => { |
|||
globalThis.fetch = originalFetch |
|||
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage) |
|||
else delete globalThis.localStorage |
|||
}) |
|||
|
|||
const sources = [{ |
|||
documentId: '9223372036854775806', title: '报销制度', sourceName: 'policy.pdf', |
|||
chunkIndex: 2, score: 0.125, snippet: '申请应在三十天内提交。', |
|||
}] |
|||
const metadata = JSON.stringify({ object: 'chat.completion.chunk', choices: [], sources }) |
|||
const answer = JSON.stringify({ choices: [{ delta: { content: '答复。' } }] }) |
|||
|
|||
function responseFor(text, { open = false, fragment = false, cancel } = {}) { |
|||
const bytes = new TextEncoder().encode(text) |
|||
const body = new ReadableStream({ |
|||
start(controller) { |
|||
if (fragment) for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)) |
|||
else controller.enqueue(bytes) |
|||
if (!open) controller.close() |
|||
}, |
|||
cancel, |
|||
}) |
|||
return new Response(body) |
|||
} |
|||
|
|||
test('fragmented CRLF metadata preserves ID/snippet and DONE completes without EOF or cancellation settlement', { timeout: 1000 }, async () => { |
|||
let cancelled = 0 |
|||
const response = responseFor( |
|||
`data: ${answer}\r\n\r\ndata: ${metadata}\r\n\r\ndata: [DONE]\r\n\r\ndata: ignored\r\n\r\n`, |
|||
{ open: true, fragment: true, cancel() { cancelled++; return new Promise(() => {}) } }, |
|||
) |
|||
const requests = [] |
|||
globalThis.fetch = async (...args) => { requests.push(args); return response } |
|||
const events = [] |
|||
await readSSEStreamWithEvents('/ai/chat/stream', { |
|||
onMessage: text => events.push(['text', text]), |
|||
onSources: value => events.push(['sources', value]), |
|||
onDone: () => events.push(['done']), |
|||
}, { Authorization: 'Bearer sdk-token' }) |
|||
assert.deepEqual(events, [['text', '答复。'], ['sources', sources], ['done']]) |
|||
assert.equal(requests.length, 1) |
|||
assert.equal(requests[0][1].headers.Authorization, 'Bearer sdk-token') |
|||
assert.equal(cancelled, 1) |
|||
assert.equal(response.body.locked, false) |
|||
}) |
|||
|
|||
test('text-only facade uses identical DONE handling and never leaks empty choices or sources', { timeout: 1000 }, async () => { |
|||
globalThis.fetch = async () => responseFor( |
|||
`data: ${metadata}\n\ndata: ${answer}\n\ndata: [DONE]\n\ndata: [DONE]\n\ndata: trailing\n\n`, |
|||
{ open: true }, |
|||
) |
|||
const text = [] |
|||
let completed = 0 |
|||
await readSSEStream('/ai/chat/stream', value => text.push(value), () => completed++) |
|||
assert.deepEqual(text, ['答复。']) |
|||
assert.equal(completed, 1) |
|||
}) |
|||
|
|||
test('EOF residuals, multiline data, tool events, status and legacy text retain their semantics', async () => { |
|||
globalThis.fetch = async () => responseFor( |
|||
': heartbeat\r\nid: event-1\r\nretry: 1000\r\nevent: status\r\ndata: generating\r\n\r\n' |
|||
+ 'event: tool_call_start\r\ndata: {"tool":"lookup"}\r\n\r\n' |
|||
+ 'event: tool_call_result\r\ndata: {"tool":"lookup","result":"ok"}\r\n\r\n' |
|||
+ 'event: error\r\ndata: {"message":"tool unavailable"}\r\n\r\n' |
|||
+ 'data: first\r\ndata: indented\r\n\r\ndata:\r\n\r\nlegacy\r\ndata: 尾部', |
|||
{ fragment: true }, |
|||
) |
|||
const events = [] |
|||
await readSSEStreamWithEvents('/stream', { |
|||
onMessage: text => events.push(text), |
|||
onToolCallStart: value => events.push(value), |
|||
onToolCallResult: value => events.push(value), |
|||
onError: value => events.push(value), |
|||
onDone: () => events.push('done'), |
|||
}) |
|||
assert.deepEqual(events, [ |
|||
{ tool: 'lookup' }, { tool: 'lookup', result: 'ok' }, { message: 'tool unavailable' }, |
|||
'first\n indented', '\n', 'legacy', '尾部', 'done', |
|||
]) |
|||
}) |
|||
|
|||
test('EOF flushes an unterminated plain-text line and an unterminated DONE event', async () => { |
|||
for (const body of ['legacy tail', 'data: [DONE]']) { |
|||
globalThis.fetch = async () => responseFor(body) |
|||
const text = [] |
|||
let done = 0 |
|||
await readSSEStream('/stream', value => text.push(value), () => done++) |
|||
assert.deepEqual(text, body.startsWith('data:') ? [] : ['legacy tail']) |
|||
assert.equal(done, 1) |
|||
} |
|||
}) |
|||
|
|||
test('cleanup failure cannot replace callback errors or call onDone on an error', async () => { |
|||
const original = new Error('consumer failed') |
|||
const response = responseFor(`data: ${answer}\n\n`, { |
|||
open: true, cancel() { throw new Error('cleanup failed') }, |
|||
}) |
|||
globalThis.fetch = async () => response |
|||
let done = 0 |
|||
await assert.rejects(readSSEStream('/stream', () => { throw original }, () => done++), error => error === original) |
|||
assert.equal(done, 0) |
|||
assert.equal(response.body.locked, false) |
|||
}) |
|||
|
|||
test('empty sources for ordinary answers are delivered separately from content', async () => { |
|||
globalThis.fetch = async () => responseFor('data: {"choices":[],"sources":[]}\n\ndata: [DONE]\n\n') |
|||
const values = [] |
|||
await readSSEStreamWithEvents('/stream', { |
|||
onMessage: () => assert.fail('metadata is not text'), |
|||
onSources: value => values.push(value), |
|||
}) |
|||
assert.deepEqual(values, [[]]) |
|||
}) |
|||
|
|||
test('synchronous chat makes one result request and retains direct answer, sources, explicit strategy and signal', async () => { |
|||
const result = { text: '答复。', mcpEvents: [], suggestions: [], sources } |
|||
const calls = [] |
|||
globalThis.fetch = async (...args) => { calls.push(args); return Response.json(result) } |
|||
const controller = new AbortController() |
|||
assert.deepEqual(await chatSync('费用?', 'conversation-1', { |
|||
enableRag: true, roleId: '9223372036854775806', rewriteStrategy: 'MULTI_QUERY', categoryIds: ['123'], |
|||
}, controller.signal), result) |
|||
assert.equal(calls.length, 1) |
|||
const url = new URL(calls[0][0], 'https://test.invalid') |
|||
assert.equal(url.pathname, '/ai/chat/result') |
|||
assert.equal(url.searchParams.get('rewriteStrategy'), 'MULTI_QUERY') |
|||
assert.equal(url.searchParams.get('roleId'), '9223372036854775806') |
|||
assert.equal(calls[0][1].signal, controller.signal) |
|||
assert.equal(calls[0][1].headers.Authorization, 'Bearer admin-token') |
|||
}) |
|||
|
|||
test('synchronous SDK response uses the same direct contract and surfaces server errors', async () => { |
|||
const result = { text: '普通回答', mcpEvents: [], suggestions: [], sources: [] } |
|||
globalThis.fetch = async () => Response.json(result) |
|||
assert.deepEqual(await fetchChatResult('https://sdk.invalid/ai/chat/result', {}), result) |
|||
globalThis.fetch = async () => new Response('角色无访问权限', { status: 403 }) |
|||
await assert.rejects(fetchChatResult('/ai/chat/result', {}), /角色无访问权限/) |
|||
await assert.rejects(readSSEStream('/ai/chat/stream', () => {}), /角色无访问权限/) |
|||
}) |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue