คู่มือ

แชทในแอป (WebView)

ฝังวิดเจ็ตแชทแบบเต็มรูปแบบลงในแอปมือถือหรือเดสก์ท็อปใดก็ได้ด้วย WebView ใช้ได้ครบทุกฟีเจอร์และอัปเดตอัตโนมัติ

วิธีที่เร็วที่สุดในการเพิ่มฝ่ายบริการลูกค้าลงในแอปเนทีฟ คือโหลดหน้าวิดเจ็ตแบบเต็มหน้าจอที่เราโฮสต์ไว้ใน WebView คุณจะได้วิดเจ็ตเดียวกับบนเว็บไซต์ทุกประการ ทั้งหน้าแรก ข้อความ ศูนย์ช่วยเหลือ การค้นหา อีโมจิ ไฟล์แนบ เวลาของข้อความ สถานะอ่านแล้ว และการปิดบทสนทนา ทั้งยังอัปเดตอัตโนมัติ เมื่อเชื่อมต่อแล้ว แอปไม่ต้องออกเวอร์ชันใหม่อีกเลยเมื่อวิดเจ็ตมีการเปลี่ยนแปลง

เริ่มต้นอย่างรวดเร็ว

ให้ WebView โหลด URL นี้ สำหรับผู้ใช้ที่ล็อกอินอยู่ ต้องใส่ externalId และ email (และควรใส่ name ด้วย) ไม่เช่นนั้นเจ้าหน้าที่จะเห็นเป็นผู้เยี่ยมชมที่ไม่ระบุชื่อเสมอ ทุกค่าต้องเข้ารหัส URL (URL-encode):

https://<your-domain>/api/widget-embed?appId=YOUR_APP_ID&externalId=USER_ID&email=USER_EMAIL&name=USER_NAME

ใช้รูปแบบไม่ระบุตัวตนเฉพาะเมื่อผู้ใช้ยังไม่ได้ล็อกอินเท่านั้น:

https://<your-domain>/api/widget-embed?appId=YOUR_APP_ID

⚠️ เจ้าหน้าที่เห็น “ผู้เยี่ยมชมที่ไม่ระบุชื่อ” โดยไม่มีอีเมล? นี่คือข้อผิดพลาดในการเชื่อมต่อที่พบบ่อยที่สุด: URL ของ WebView ไม่มี email/externalId เซิร์ฟเวอร์สร้างค่าเหล่านี้ขึ้นเองไม่ได้ แอปต้องส่งมาเอง หาก ID ของผู้เยี่ยมชมมีหน้าตาแบบ anon-… แปลว่าไม่มีการส่งอะไรมาเลย

JavaScript / WebView ใดก็ได้ (ไม่ใช่ Flutter)

สำหรับ Electron, เชลล์เดสก์ท็อป, React Native หรือ WKWebView แบบเนทีฟที่คุณกำหนด URL เอง ให้สร้าง URL ด้วย JS โดย URLSearchParams จะเข้ารหัสทุกค่าให้อัตโนมัติ:

// Build the support URL for the signed-in user before opening the WebView.
function buildSupportUrl(domain, user) {
  const params = new URLSearchParams({
    appId: 'YOUR_APP_ID',
    platform: 'windows',            // Strongly recommended: decides which help articles this visitor sees
                                    // ios / macos_appstore / macos / android / windows / web
    // locale: userSelectedLanguage, // Optional: only if your app has its own language switch; also add forceLocale: '1'
                                    // Omitted = follow the device language (covers the App Store's 50 localizations)
    // — Always pass these for signed-in users, or agents only see an "anonymous visitor" —
    externalId: user.id,            // The user's unique ID in your system
    email: user.email,              // The user's email (important)
    name: user.name || '',          // The user's name (optional)
    // hmac: user.hmac,             // Optional: computed on your server to prevent impersonation (see HMAC below)
    // attrs: JSON.stringify({ plan: user.plan, expiresAt: user.expireAt }), // Optional: custom attributes
  });
  return `https://${domain}/api/widget-embed?${params.toString()}`;
}

// Usage: load the returned url in your WebView (instead of the old ?appId=...-only URL).
const url = buildSupportUrl('askais.com', currentUser);
myWebView.loadURL(url); // Electron: win.loadURL(url); native: load this URL

Flutter (webview_flutter)

เพิ่ม webview_flutter: ^4.x ลงใน pubspec.yaml จากนั้น:

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class SupportPage extends StatefulWidget {
  const SupportPage({super.key});
  @override
  State<SupportPage> createState() => _SupportPageState();
}

class _SupportPageState extends State<SupportPage> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();
    // For signed-in users always include externalId + email, or agents see an "anonymous visitor" (no name / email).
    // Pass only appId + locale when the user is not signed in. Uri.https URL-encodes the values.
    final uri = Uri.https('askais.com', '/api/widget-embed', {
      'appId': 'YOUR_APP_ID',
      'platform': Platform.isIOS ? 'ios' : 'android', // Strongly recommended: decides which help articles are visible
      // No locale = follow the device language (covers the App Store's 50 localizations).
      // Only pass it if your app has its own language switch, together with 'forceLocale': '1':
      // 'locale': appSettings.selectedLanguage,
      'externalId': user.id, // The user's unique ID in your system (important)
      'email': user.email, // The user's email (important)
      'name': user.name, // Optional
      // 'hmac': hmacFromYourServer, // Optional: computed on your server to prevent impersonation
    });
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setBackgroundColor(Colors.white)
      ..loadRequest(uri);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Support')),
      // Full-screen: drop the appBar and use your own close button.
      body: SafeArea(child: WebViewWidget(controller: _controller)),
    );
  }
}

// Open it from wherever your "Support" button lives:
// Navigator.push(context,
//   MaterialPageRoute(builder: (_) => const SupportPage()));

สำหรับ WebView บน iOS/Android ในสแต็กอื่น (Swift WKWebView, Kotlin WebView, React Native react-native-webview) หลักการเหมือนกันทุกประการ คือโหลด URL และเปิดใช้ JavaScript

iOS และ Android แบบเนทีฟ

ไม่ต้องติดตั้ง SDK ใด ๆ แค่สร้าง URL แบบเดียวกันแล้วส่งให้ WebView โปรดสังเกตกรณีของ Mac: บิลด์สำหรับ App Store และบิลด์ DMG ทำงานบนระบบปฏิบัติการเดียวกัน ค่านี้จึงต้องมาจากแฟล็กตอนบิลด์ ไม่ใช่จากการตรวจสอบตอนรันไทม์

// ── iOS (Swift / WKWebView) ────────────────────────────────
// On Mac Catalyst / macOS, set platform to macos_appstore or macos.
#if targetEnvironment(macCatalyst)
let platform = "macos_appstore"   // App Store build; a DMG build from your website passes "macos"
#else
let platform = "ios"              // iPhone and iPad are both ios
#endif

var comps = URLComponents(string: "https://\(domain)/api/widget-embed")!
comps.queryItems = [
    .init(name: "appId", value: "YOUR_APP_ID"),
    .init(name: "platform", value: platform),
    .init(name: "externalId", value: user.id),     // Required for signed-in users
    .init(name: "email", value: user.email),       // Required for signed-in users
    .init(name: "name", value: user.name),
    // Only pass these two if your app has its own language switch:
    // .init(name: "locale", value: settings.language),
    // .init(name: "forceLocale", value: "1"),
]
webView.load(URLRequest(url: comps.url!))          // URLComponents URL-encodes the values


// ── Android (Kotlin / WebView) ─────────────────────────────
val url = Uri.parse("https://$domain/api/widget-embed")
    .buildUpon()
    .appendQueryParameter("appId", "YOUR_APP_ID")
    .appendQueryParameter("platform", "android")
    .appendQueryParameter("externalId", user.id)
    .appendQueryParameter("email", user.email)
    .appendQueryParameter("name", user.name)
    // .appendQueryParameter("locale", settings.language)
    // .appendQueryParameter("forceLocale", "1")
    .build()

webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true          // The widget uses localStorage to remember the visitor
webView.loadUrl(url.toString())

พารามิเตอร์ของ URL

  • appId (จำเป็น) คือ App ID ของกล่องข้อความของคุณ
  • locale คือภาษาของอินเทอร์เฟซ แทบทุกกรณีไม่ต้องส่งค่านี้ เพราะวิดเจ็ตจะใช้ภาษาของอุปกรณ์ตามที่ WebView รายงาน ซึ่งตรงกับที่ผู้ใช้คาดหวัง อินเทอร์เฟซของวิดเจ็ตแปลไว้ครบทั้ง 50 ภาษาที่ App Store Connect รองรับ ส่วนภาษาที่อยู่นอกรายการจะใช้ภาษาอังกฤษแทน ให้ส่งค่านี้เฉพาะเมื่อแอปของคุณมีตัวสลับภาษาในแอปเอง โดยส่งภาษาที่ผู้ใช้เลือกพร้อม forceLocale=1 เพื่อให้มีผลเหนือการตั้งค่าของอุปกรณ์ ห้ามกำหนดค่าตายตัวเด็ดขาด การฮาร์ดโค้ด zh-Hans คือสาเหตุที่แอปภาษาญี่ปุ่นอาจแสดงวิดเจ็ตเป็นภาษาจีน
  • platform (แนะนำอย่างยิ่ง) ระบุว่าผู้เยี่ยมชมใช้บิลด์ใด ค่านี้กำหนดว่าผู้เยี่ยมชมจะเห็นบทความช่วยเหลือใดบ้าง และ AI จะตอบภายใต้กฎของ App Store หรือไม่ หากไม่ส่ง ระบบจะเดาแพลตฟอร์มจาก User-Agent ของ WebView ซึ่งแยกได้ว่าเป็น iPhone, Android หรือ Windows แต่แยกบิลด์ Mac App Store กับบิลด์ DMG ไม่ออก และไม่รู้จัก iPad ที่อยู่ในโหมดเดสก์ท็อป ค่าที่ใช้ได้: ios (iPhone และ iPad), macos_appstore (บิลด์ Mac App Store), macos (บิลด์ DMG ที่แจกจ่ายโดยตรง), android, windows, web
  • externalId คือ ID ผู้ใช้ที่ไม่ซ้ำกันในแอปของคุณ การส่งค่านี้ช่วยให้เจ้าหน้าที่จำผู้ใช้ได้และรวมประวัติการสนทนาของผู้ใช้เข้าด้วยกัน
  • email, name, avatarUrl คือข้อมูลโปรไฟล์ที่แสดงให้เจ้าหน้าที่เห็น
  • hmac คือลายเซ็นยืนยันตัวตน (ดูด้านล่าง) ไม่บังคับ

ซ่อนเนื้อหาตามแพลตฟอร์ม (การปฏิบัติตามกฎ App Store)

กฎข้อ 3.1.1 ของ App Store ไม่อนุญาตให้แอป iOS แสดงเนื้อหาเกี่ยวกับการซื้อหรือการสมัครสมาชิกนอกแอป รวมถึงการแนะนำ (referral) ดังนั้นบทความช่วยเหลือและคำถามที่พบบ่อยทุกรายการจึงมีการตั้งค่าการแสดงผลแยกตามแพลตฟอร์ม เพียงเปิดหน้าศูนย์ช่วยเหลือหรือคำถามที่พบบ่อยในแดชบอร์ด แล้วสลับชิปแพลตฟอร์มของรายการนั้น ผู้เยี่ยมชมบนแพลตฟอร์มที่เลือกจะไม่เห็นรายการนั้นในลิสต์ และเปิดผ่านลิงก์ตรงก็ไม่ได้

AI ไม่ได้ตอบจากบทความช่วยเหลือหรือคำถามที่พบบ่อย แต่ตอบจากสิ่งที่คุณเพิ่มไว้ใน “Training” ไฟล์และคู่คำถาม-คำตอบในนั้นมีชิปแพลตฟอร์มแบบเดียวกัน รายการที่ซ่อนจากแพลตฟอร์มใดจะไม่ถูกใช้ตอบผู้เยี่ยมชมบนแพลตฟอร์มนั้น หากไม่ต้องการให้ AI พูดถึงด้วย ให้ซ่อนทั้งสองที่

มีสองเรื่องที่ตัดสินว่าฟีเจอร์นี้จะทำงานได้อย่างแน่นอนหรือไม่ อย่างแรก แอปของคุณควรส่ง platform มาเอง การเดาจาก User-Agent เป็นแค่ทางสำรอง อย่างที่สอง ค่านั้นต้องสะท้อนบิลด์ ไม่ใช่แค่ระบบปฏิบัติการ:

  • iPad ไม่ได้แยกต่างหาก iPad รันแอป iOS ตัวเดียวกันภายใต้กฎชุดเดียวกัน ดังนั้น ios จึงครอบคลุมทั้ง iPhone และ iPad
  • Mac มีสองบิลด์ บิลด์ Mac App Store ต้องปฏิบัติตามกฎข้อ 3.1.1 แต่ไฟล์ DMG ที่คุณแจกจ่ายจากเว็บไซต์ของตัวเองไม่ต้อง ทั้งสองทำงานบนระบบปฏิบัติการเดียวกัน จึงไม่มีสิ่งใดตอนรันไทม์ที่แยกทั้งสองออกจากกันได้ ให้ตัดสินตอนบิลด์ (เช่น ด้วยแฟล็กของ Xcode) แล้วส่ง macos_appstore หรือ macos ให้ตรงกัน หากตั้งผิด บิลด์สำหรับสโตร์อาจแสดงเนื้อหาการสมัครสมาชิกระหว่างการรีวิว หรือบิลด์ DMG อาจซ่อนศูนย์ช่วยเหลือไปครึ่งหนึ่งโดยไม่มีเหตุผล

การเปลี่ยนแปลงมีผลทันที เพราะการตั้งค่านี้อยู่ในแดชบอร์ด ไม่ได้อยู่ในแอป คุณจึงไม่ต้องออกเวอร์ชันใหม่เพื่อเปลี่ยนสิ่งที่แสดง

การยืนยันตัวตน (HMAC, ไม่บังคับ)

หากต้องการพิสูจน์ว่า externalId เป็นผู้ใช้ที่ล็อกอินอยู่ของคุณจริง (และป้องกันการสวมรอย) ให้ส่งลายเซ็น HMAC มาด้วย โดยคำนวณบนเซิร์ฟเวอร์ของคุณ ห้ามใส่คีย์ลับไว้ในแอปเด็ดขาด

  • hmac = HMAC-SHA256(secretKey, externalId) โดยแสดงผลเป็นเลขฐานสิบหกตัวพิมพ์เล็ก
  • ข้อมูลที่ใช้ลงนาม (payload) คือ externalId จึงต้องส่งมาด้วยทุกครั้ง หากไม่ส่ง วิดเจ็ตจะใช้ ID นิรนามของตัวเอง และลายเซ็นที่ลงนามจาก email อย่างเดียวจะถูกปฏิเสธ
  • secretKey คือคีย์ลับของกล่องข้อความ (หนึ่งคีย์ต่อหนึ่งกล่องข้อความ ดูได้ในการตั้งค่ากล่องข้อความ) หากไม่ส่ง hmac ผู้เยี่ยมชมจะถือว่ายังไม่ได้ยืนยันตัวตน
import { createHmac } from 'node:crypto';

// SECRET_KEY lives only on your server, never ship it inside the app.
const hmac = createHmac('sha256', SECRET_KEY)
  .update(externalId)   // exactly the externalId you pass to the widget
  .digest('hex');

// Return { externalId, email, hmac } to the app, which appends them to the URL.

หมายเหตุสำหรับแต่ละแพลตฟอร์ม

  • Android: คงสิทธิ์ INTERNET ไว้ และดักปุ่มย้อนกลับให้ย้อนกลับภายใน WebView ก่อน webview_flutter เวอร์ชันใหม่รองรับ <input type="file"> สำหรับแนบไฟล์
  • iOS: ใช้ HTTPS ได้โดยไม่ต้องมีข้อยกเว้น ATS การอัปโหลดรูปภาพเป็นฟีเจอร์ในตัวของวิดเจ็ต WKWebView จะเปิดตัวเลือกรูปภาพแบบเนทีฟเอง คุณจึงไม่ต้องสร้างอะไรเพิ่ม การเลือกรูปจากคลังรูปภาพไม่ต้องขอสิทธิ์ใด ๆ ส่วน NSCameraUsageDescription ใน Info.plist จำเป็นเฉพาะเมื่อต้องการตัวเลือกกล้อง “ถ่ายรูป” หากไม่ใส่ แอปจะแครชเมื่อผู้ใช้แตะตัวเลือกนั้น

ต้องการ UI แบบเนทีฟ?

หากคุณต้องการหน้าจอแชทแบบเนทีฟที่สร้างเองแทน WebView ให้ใช้ไคลเอนต์ที่มีเฉพาะส่วนตรรกะในหน้า SDK สำหรับมือถือ แต่สำหรับแอปส่วนใหญ่ WebView ข้างต้นเปิดใช้งานได้เร็วกว่าและมีฟีเจอร์ครบเสมอ