เอ็กซ์เทนชัน AI
เอ็กซ์เทนชัน AI เพิ่มปุ่มบนแถบเครื่องมือที่เปิดกล่องโต้ตอบคำสั่ง ส่งคำขอไปยังผู้ให้บริการที่คุณเลือก แสดงผลลัพธ์แบบสตรีม และแทรกลงในเอดิเตอร์ผ่านปุ่มการทำงานตามโหมด (แทรก / แทนที่ / เพิ่มที่ท้าย)
<Tiptapify
:content="content"
:items="['ai', '|', 'bold', 'italic']"
:ai="{
aiProvider,
model: 'gpt-4.1-mini',
stream: true,
}"
/>prop ai รับ true (ตัวเลือกค่าเริ่มต้น) หรือออบเจกต์ตัวเลือก (TiptapifyAiOptions) รายการ ai บนแถบเครื่องมือถูกลงทะเบียนเสมอ แต่ปุ่มจะถูกปิดใช้งานหากไม่ได้ตั้งค่า aiProvider
ดูตัวอย่าง AI สำหรับเดโมที่รันได้ และProps สำหรับเอกสารอ้างอิงตัวเลือกฉบับเต็ม
สถาปัตยกรรม
Tiptapify.vue Toolbar item `ai` Dialog.vue
resolves the `ai` -> Button.vue -> builds the request
prop into a resolved -> (disabled without -> calls aiProvider(request, context, stream)
options object and -> aiProvider) -> streams chunks into result/reasoning fields
provides it (inject -> opens the dialog -> the action button (Insert/Replace/Append) places the result into the editor
'tiptapifyAi')Tiptapifyปรับ propaiให้เป็นรูปแบบมาตรฐาน:ai: trueจะกลายเป็นออบเจกต์ตัวเลือกว่างpromptExamplesถอยกลับไปใช้ค่าเริ่มต้นตามภาษา และผลลัพธ์ถูกเปิดให้คอมโพเนนต์ลูกผ่านinject('tiptapifyAi')(TiptapifyAiResolvedOptionsหรือfalse)- รายการบนแถบเครื่องมือ (
src/components/Toolbar/ai.ts) เรนเดอร์Button.vueปุ่มจะตรวจสอบว่ามีaiProviderหรือไม่ และเปิดDialog.vue Dialog.vueดูแลวงจรชีวิตของคำขอทั้งหมด: การสร้างคำขอ การเรียกผู้ให้บริการ การสตรีม และการแทรกผลลัพธ์
สัญญาของผู้ให้บริการ
type TiptapifyAiProvider = (
request: TiptapifyAiRequest,
context: TiptapifyAiEditorContext,
stream?: TiptapifyAiStream,
) => Promise<TiptapifyAiResponse | TiptapifyAiOpenAiResponse | string>ผู้ให้บริการคือฟังก์ชัน async ธรรมดาที่คุณเขียนเอง tiptapify ไม่เรียก API ใด ๆ เอง — อะไรก็ตามที่รับออบเจกต์ request แบบที่รองรับ OpenAI ได้ก็ใช้ได้ (fetch, SDK, อะแดปเตอร์ฝั่งแบ็กเอนด์) ข้อยกเว้นคือโหมดแบ็กเอนด์ aiEndpoint (ดูการสร้างผ่านแบ็กเอนด์) ซึ่ง tiptapify ส่งคำขอไปยัง endpoint ของคุณ
Request (TiptapifyAiRequest) ถูกกระจายมาจาก chatCompletionOptions ของคุณ แล้วปลั๊กอินเพิ่มเติมให้:
| ฟิลด์ | แหล่งที่มา |
|---|---|
messages | createMessages(context) หากมี มิฉะนั้นเป็นคู่ค่าเริ่มต้น: system message (systemPrompt ของคุณหรือค่าเริ่มต้นในตัว) และ user message ที่ฝังคำสั่งพร้อมข้อความที่เลือกในเอดิเตอร์ รวมถึงข้อความรอบ ๆ และกฎให้ตอบเฉพาะส่วนที่เลือก (หรือทั้งข้อความเมื่อไม่ได้เลือกอะไร) |
model | ตัวเลือก model เมื่อมีการตั้งค่า |
temperature | ตัวเลือก temperature เมื่อมีการตั้งค่า |
stream | ตัวเลือก stream หรือ chatCompletionOptions.stream |
enable_thinking / reasoning_effort | สวิตช์การคิด / ดรอปดาวน์ระดับการให้เหตุผล — ดูด้านล่าง |
Context (TiptapifyAiEditorContext) ให้สถานะเอดิเตอร์ทั้งหมดแก่ผู้ให้บริการ: prompt, selectedText, textBefore / textAfter (ข้อความของย่อหน้าที่มีส่วนที่เลือกอยู่รอบ ๆ ส่วนที่เลือก ด้านละไม่เกิน 500 อักขระ), text, html, json และ mode (insert / replace / append)
ส่วนที่เลือกซึ่งเริ่มหรือสิ้นสุดกลางคำจะขยายให้ครอบคลุมทั้งคำ และข้อความเริ่มต้นจะส่งข้อความรอบส่วนที่เลือกพร้อมกฎให้ตอบเฉพาะส่วนที่เลือก: หากได้รับเพียงเศษข้อความ โมเดลจะนึกว่าเป็นข้อความทั้งหมดแล้วต่อให้จบ และข้อความที่แทนที่จะซ้ำกับสิ่งที่ตามหลังส่วนที่เลือก
Response: ผู้ให้บริการอาจส่งเนื้อหากลับเป็น string ธรรมดา เป็น { content } (TiptapifyAiResponse) หรือเป็นเพย์โหลด choices แบบที่รองรับ OpenAI (TiptapifyAiOpenAiResponse) กล่องโต้ตอบจะดึงเนื้อหาตามลำดับนั้นและตัดช่องว่างนำหน้า เมื่อถูกปฏิเสธ ข้อความผิดพลาดจะแสดงในกล่องโต้ตอบ (หรือ ai.error เมื่อข้อผิดพลาดไม่ใช่อินสแตนซ์ของ Error) และกล่องโต้ตอบยังคงเปิดอยู่
การสร้างผ่านแบ็กเอนด์ (aiEndpoint)
แทนที่จะเขียน aiProvider ให้ชี้ฟีเจอร์ AI ไปที่ endpoint LLM ของแบ็กเอนด์:
// example
const ai = {
aiEndpoint: '/api/ai/generate',
model: 'gpt-4.1-mini',
stream: true,
}จากนั้น tiptapify จะสร้างคำขอและส่งแบบ POST ไปยัง endpoint เอง หากตั้งค่าทั้งสองอย่าง aiProvider ที่ระบุชัดเจนจะมีผลเหนือกว่าเสมอ
Request — เพย์โหลด JSON ขั้นต่ำ (TiptapifyAiBackendRequest):
| ฟิลด์ | เมื่อไรที่ส่ง |
|---|---|
prompt | ส่งเสมอ — คำขอที่พิมพ์ในกล่องโต้ตอบ |
instruction | ส่งเสมอ — systemPrompt ของคุณ system message ของ createMessages แบบกำหนดเอง หรือค่าเริ่มต้นในตัว |
stream | ส่งเมื่อ stream: true เท่านั้น |
thinking | ส่งเมื่อสวิตช์ การคิด ในกล่องโต้ตอบเปิดอยู่เท่านั้น |
reasoning_effort | ส่งเมื่อผู้ใช้เลือกระดับ (ที่ไม่ใช่ default) เท่านั้น |
model | ส่งเมื่อตั้งค่าตัวเลือก model เท่านั้น |
Response — ยอมรับอย่างยืดหยุ่น: สตริงธรรมดา ออบเจกต์ { content } หรือเพย์โหลด choices แบบที่รองรับ OpenAI
Streaming — เมื่อคำขอมี stream: true เนื้อหาการตอบกลับจะถูกอ่านเป็นสตรีม SSE (บรรทัด data: สิ้นสุดด้วย data: [DONE]) รูปแบบชังก์ที่ยอมรับ:
- แบบ OpenAI —
data: {"choices":[{"delta":{"content":"…", "reasoning_content":"…"}}]} - แบบง่าย —
data: {"type":"content"|"reasoning","data":"…"} - แบบแบน —
data: {"content":"…","reasoning_content":"…"} - ข้อความธรรมดา —
data: some text(บรรทัดdataที่ไม่ใช่ JSON ถือเป็นชังก์เนื้อหา)
ชังก์ content จะอัปเดตช่องผลลัพธ์ผ่าน onChunk ส่วนชังก์ reasoning_content จะป้อนแผง การคิด ผ่าน onReasoning หากแบ็กเอนด์ไม่สนใจ stream และตอบกลับเป็นเนื้อหาธรรมดา จะใช้เนื้อหานั้นแทน การคลิก หยุด หรือการปิดกล่องโต้ตอบจะยกเลิกคำขอ เนื้อหาที่สตรีมมาบางส่วนจะยังคงอยู่ในช่องผลลัพธ์
Headers — aiHeaders (Record<string, string>) จะถูกรวมทับ Content-Type: application/json header Authorization ใน aiHeaders จะคงไว้ตามเดิม มิฉะนั้นโทเค็นจาก tokenProvider จะถูกส่งเป็น Authorization: Bearer <token>
Errors — การตอบกลับที่ไม่ใช่ OK จะแสดงข้อความผิดพลาดของแบ็กเอนด์ (JSON { error } / { message } หรือเนื้อหาดิบ) ในกล่องโต้ตอบ
การสตรีม
เมื่อ stream: true (หรือ chatCompletionOptions.stream เป็น true) กล่องโต้ตอบจะส่งออบเจกต์ TiptapifyAiStream เป็นอาร์กิวเมนต์ที่สาม:
type TiptapifyAiStream = {
signal: AbortSignal,
onChunk: (chunk: string) => void,
onReasoning: (chunk: string) => void,
}signal— ส่งให้fetch(หรือการเรียก SDK ของคุณ) จะยกเลิกเมื่อผู้ใช้คลิก หยุด หรือปิดกล่องโต้ตอบ คำขอที่กำลังดำเนินอยู่จึงไม่ทำงานต่อonChunk— ต่อแต่ละชังก์เนื้อหาเข้ากับช่องผลลัพธ์ ระหว่างการสร้าง ช่องผลลัพธ์เป็นแบบอ่านอย่างเดียวและเนื้อหาที่สตรีมมาบางส่วนจะยังคงอยู่ (คุณหยุดแล้วแทรกหรือทิ้งก็ได้)onReasoning— ส่งต่อผลลัพธ์การให้เหตุผล/การคิดของโมเดล (เช่นเดลตาreasoning_contentของ Qwen/DeepSeek/ซีรีส์ o) ไปยังแผง การคิด แบบพับได้เหนือผลลัพธ์ แผงจะขยายและเลื่อนอัตโนมัติขณะสตรีม ผลลัพธ์การให้เหตุผลใช้แสดงเท่านั้นและไม่เคยถูกแทรกลงในเอดิเตอร์
ผู้ให้บริการที่ไม่สตรีมจะไม่สนใจอาร์กิวเมนต์ stream — เป็นทางเลือก
การคิดและระดับการให้เหตุผล
ตัวควบคุมสองอย่างของผู้ใช้ในกล่องโต้ตอบแมปกับฟิลด์ของคำขอ ทั้งสองต้องเปิดใช้ผ่านตัวเลือก และปลั๊กอินเป็นผู้จัดการ — คุณไม่ต้อง (และไม่ควร) ตั้งฟิลด์เหล่านี้เองใน chatCompletionOptions
thinking: trueแสดงสวิตช์ การคิด (เปิดโดยค่าเริ่มต้น รีเซ็ตทุกครั้งที่เปิดกล่องโต้ตอบ) ปลั๊กอินเขียนค่าของสวิตช์ลงในคำขอเป็นenable_thinking: true | false- ไม่มี
thinkingหรือthinking: falseซ่อนสวิตช์ และปลั๊กอินส่งenable_thinking: falseโมเดลที่คิดโดยค่าเริ่มต้นจึงไม่คิด reasoningEffort: { options, default? }แสดงดรอปดาวน์ ระดับการให้เหตุผลoptionsระบุรายการระดับความพยายามแบบกำหนดเองที่ต้องการเสนอ ('low' | 'medium' | 'high' | 'xhigh' | 'max') เอ็กซ์เทนชันจะเพิ่มรายการdefault(ค่าพื้นฐานของโหมดการคิด) ให้เอง คุณจึงไม่ต้องใส่defaultในคอนฟิก ฟิลด์defaultใช้เลือกระดับแบบกำหนดเองไว้ล่วงหน้าเมื่อเปิดกล่องโต้ตอบ — หากไม่ระบุ จะเลือกdefaultไว้ล่วงหน้า ค่าจะรีเซ็ตทุกครั้งที่เปิด ดรอปดาวน์จะแสดงเมื่อoptionsเป็นรายการที่ไม่ว่างthinkingเปิดใช้งาน และสวิตช์การคิดเปิดอยู่เท่านั้นshowReasoning(ค่าเริ่มต้นfalse) ควบคุมแผง การคิด แบบพับได้ที่สตรีมกระบวนการให้เหตุผลของโมเดล (เดลตาreasoning_content) ตั้งshowReasoning: trueเพื่อแสดงแผงนั้นโดยยังเปิดการคิดอยู่ — โมเดลยังคงให้เหตุผลและผลลัพธ์ถูกนำไปใช้เหมือนเดิม เพียงแต่แสดงการให้เหตุผลทีละขั้นเพิ่มเติม
เนื่องจากผู้ให้บริการ (โดยเฉพาะ vLLM/LM Studio) ปฏิเสธ enable_thinking และ reasoning_effort ในคำขอเดียวกัน ปลั๊กอินจึงส่งเพียงหนึ่งในสองอย่างเท่านั้น:
| สถานะ | ฟิลด์ของคำขอ |
|---|---|
เปิดการคิด ระดับ = default (หรือไม่ได้ตั้งค่าระดับ) | enable_thinking (จากสวิตช์) |
เปิดการคิด ระดับ = low / medium / high / xhigh / max | reasoning_effort (จากดรอปดาวน์) สวิตช์ถูกปิดใช้งาน |
ปิดการคิด (ปิดสวิตช์ หรือไม่ได้เปิด thinking ในคอนฟิก) | enable_thinking: false ไม่ส่ง reasoning_effort |
ข้อผิดพลาด
กล่องโต้ตอบจะอธิบายคำขอที่ล้มเหลวแทนการแสดงข้อผิดพลาดดิบ และเพิ่มข้อความของบริการเองต่อท้าย:
| สิ่งที่เกิดขึ้น | คำอธิบาย |
|---|---|
เบราว์เซอร์ไม่ได้รับการตอบกลับ (Failed to fetch, NetworkError…, Load failed) | บริการล่ม เข้าถึงไม่ได้ หรือ CORS บล็อกคำตอบ คอนโซลของเบราว์เซอร์จะบอกสาเหตุ |
401 / 403 | ตรวจสอบคีย์ API หรือสิทธิ์การเข้าถึง |
404 | ตรวจสอบเส้นทางของ endpoint |
408 / 504 / timeout | บริการไม่ตอบทันเวลา |
429 | มีคำขอมากเกินไป |
5xx | บริการไม่พร้อมใช้งาน (กำลังโหลดโมเดล หรือไม่ว่าง) |
เมื่อใช้ aiEndpoint สถานะมาจากการตอบกลับ aiProvider แบบกำหนดเองได้รับคำอธิบายแบบเดียวกันโดยโยนข้อผิดพลาดที่มี status:
if (!response.ok) {
const body = await response.json().catch(() => null)
throw Object.assign(new Error(body?.error?.message ?? ''), { status: response.status })
}ข้อผิดพลาดอื่น ๆ จะแสดงด้วยข้อความของตัวเอง
การแทรกผลลัพธ์
เมื่อกดปุ่มการทำงาน — แทรก แทนที่ หรือ เพิ่มที่ท้าย ตามโหมด — กล่องโต้ตอบจะใส่ result ลงในเอดิเตอร์:
- โหมด — ปุ่มการทำงานของกล่องโต้ตอบเป็นปุ่มแบบแยกส่วน ส่วนหลักใช้ผลลัพธ์ในโหมดที่เลือกไว้ล่วงหน้า (แทรก / แทนที่ / เพิ่มที่ท้าย): ตัวเลือก
modeเลือกโหมดไว้ล่วงหน้า หรือเป็น แทนที่ เมื่อมีการเลือกข้อความตอนเปิดกล่องโต้ตอบ และเป็น แทรก ในกรณีอื่น ส่วน "เพิ่มเติม" (⋮) เล็ก ๆ ทางขวาของปุ่มจะเปิดเมนูที่มีโหมดที่เหลือ — การคลิกโหมดใดจะใช้ผลลัพธ์ในโหมดนั้นทันที แทรก วางผลลัพธ์ที่เคอร์เซอร์ แทนที่ แทนที่ข้อความที่เลือก (บันทึกไว้ตอนคลิก สร้าง) และ เพิ่มที่ท้าย เพิ่มที่ท้ายเอกสาร - ขีดจำกัดอักขระ — เมื่อตั้งค่าเอดิเตอร์ด้วย
limit(เอ็กซ์เทนชันCharacterCountจะปฏิเสธทรานแซกชันที่ไม่ใช่การวางซึ่งทำให้เอกสารยาวเกินขีดจำกัด) จะแสดงตัวนับอักขระgenerated / limitใต้ช่องผลลัพธ์ (อัปเดตสดขณะสตรีม และเป็นสีแดงอ่อนเมื่อจะเกินขีดจำกัด) และกล่องโต้ตอบจะแสดงคำเตือนai.limit_exceededทันทีหลังการสร้างเมื่อผลลัพธ์จะเกินขีดจำกัด ปุ่ม แทรก และเมนูโหมดอื่นจะถูกปิดใช้งาน: ผลลัพธ์ถูกใช้แบบทั้งหมดหรือไม่เลย คุณจึงต้องทำให้สั้นลงก่อนนำไปใช้