From 3523a20a5922bb346dbd0030458afba529e445b0 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Thu, 22 Oct 2026 07:23:12 +0800 Subject: [PATCH 1/3] chore(platform): keep secrets in the platform key store --- .../kotlin/com/exptech/dpip/MainActivity.kt | 3 + .../com/exptech/dpip/SecretStoreChannel.kt | 150 ++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 8 + ios/Runner/AppDelegate.swift | 1 + ios/Runner/SecretStorePlugin.swift | 108 +++++++++++++ ios/RunnerTests/SecretStorePluginTests.swift | 66 ++++++++ lib/core/platform/secret_store.dart | 104 ++++++++++++ test/core/platform/secret_store_test.dart | 64 ++++++++ 8 files changed, 504 insertions(+) create mode 100644 android/app/src/main/kotlin/com/exptech/dpip/SecretStoreChannel.kt create mode 100644 ios/Runner/SecretStorePlugin.swift create mode 100644 ios/RunnerTests/SecretStorePluginTests.swift create mode 100644 lib/core/platform/secret_store.dart create mode 100644 test/core/platform/secret_store_test.dart diff --git a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt index 28a31db22..18907021a 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt @@ -53,6 +53,9 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, BackgroundExecutionChannel.NAME) .setMethodCallHandler(BackgroundExecutionChannel(applicationContext)) + MethodChannel(messenger, SecretStoreChannel.NAME) + .setMethodCallHandler(SecretStoreChannel(applicationContext)) + // Activity-scoped: the keep-awake window flag belongs to this window. MethodChannel(messenger, ScreenWakeChannel.NAME) .setMethodCallHandler(ScreenWakeChannel(this)) diff --git a/android/app/src/main/kotlin/com/exptech/dpip/SecretStoreChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/SecretStoreChannel.kt new file mode 100644 index 000000000..d3aaf30c9 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/SecretStoreChannel.kt @@ -0,0 +1,150 @@ +package com.exptech.dpip + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.security.GeneralSecurityException +import java.security.KeyStore +import java.util.concurrent.Executors +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Secrets the user hands the app — today the AI chat's API key. + * + * The AES-GCM key is generated inside the Android Keystore and never leaves + * it; this class only ever holds ciphertext, written to `noBackupFilesDir` so + * that even with backups enabled the file would stay on this device. Each file + * is the 12-byte IV followed by the ciphertext and its tag. + * + * A file the Keystore can no longer decrypt (its key was invalidated, e.g. by + * removing the lock screen) is deleted and read back as absent: there is + * nothing else to be done with it, and the user simply enters the key again. + * + * Keystore work runs off the main thread; results are posted back to it. + */ +class SecretStoreChannel(context: Context) : MethodChannel.MethodCallHandler { + + companion object { + const val NAME = "com.exptech.dpip/secret_store" + private const val KEY_ALIAS = "dpip_secret_store_v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_BYTES = 12 + private const val TAG_BITS = 128 + + /** Secret names are a fixed set on the Dart side; this keeps a bad + * one from naming a path outside the secrets directory. */ + private val SAFE_NAME = Regex("^[A-Za-z0-9._-]{1,64}$") + } + + private val directory = File(context.noBackupFilesDir, "secrets") + private val worker = Executors.newSingleThreadExecutor() + private val main = Handler(Looper.getMainLooper()) + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("key") + if (name == null || !SAFE_NAME.matches(name)) { + result.error("bad_key", "missing or invalid key name", null) + return + } + when (call.method) { + "read" -> run(result) { read(name) } + "write" -> { + val value = call.argument("value") + if (value == null) { + result.error("bad_value", "missing value", null) + return + } + run(result) { write(name, value); null } + } + "delete" -> run(result) { fileFor(name).delete(); null } + else -> result.notImplemented() + } + } + + private fun run(result: MethodChannel.Result, work: () -> String?) { + worker.execute { + try { + val value = work() + main.post { result.success(value) } + } catch (error: Exception) { + // The class name only: a message could quote what failed. + main.post { + result.error("failed", error.javaClass.simpleName, null) + } + } + } + } + + private fun fileFor(name: String) = File(directory, "$name.bin") + + private fun read(name: String): String? { + val file = fileFor(name) + if (!file.exists()) return null + val bytes = file.readBytes() + if (bytes.size <= IV_BYTES) { + file.delete() + return null + } + return try { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + existingKey() ?: run { file.delete(); return null }, + GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES), + ) + String( + cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), + Charsets.UTF_8, + ) + } catch (error: GeneralSecurityException) { + file.delete() + null + } + } + + private fun write(name: String, value: String) { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, existingKey() ?: newKey()) + val sealed = cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + directory.mkdirs() + // Write beside, then rename: a crash mid-write must not leave a file + // that fails its tag and erases the previous key on the next read. + val staging = File(directory, "$name.tmp") + staging.writeBytes(sealed) + if (!staging.renameTo(fileFor(name))) { + staging.delete() + throw IllegalStateException("rename failed") + } + } + + private fun existingKey(): SecretKey? { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + return store.getKey(KEY_ALIAS, null) as SecretKey? + } + + private fun newKey(): SecretKey { + val generator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + "AndroidKeyStore", + ) + generator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 9e68bd47d..f9a90945a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -42,6 +42,8 @@ AA0000000000000000000C02 /* CompassPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000C01 /* CompassPlugin.swift */; }; AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; }; AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000E01 /* ScreenWakePlugin.swift */; }; + SK0S00000000000000000002 /* SecretStorePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = SK0S00000000000000000001 /* SecretStorePlugin.swift */; }; + SK0S00000000000000000004 /* SecretStorePluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SK0S00000000000000000003 /* SecretStorePluginTests.swift */; }; AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000F01 /* ApnsTokenPlugin.swift */; }; AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000G01 /* LocationTrackStore.swift */; }; AA0000000000000000000H02 /* NetworkPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000H01 /* NetworkPlugin.swift */; }; @@ -180,6 +182,8 @@ AA0000000000000000000C01 /* CompassPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompassPlugin.swift; sourceTree = ""; }; AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = ""; }; AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; }; + SK0S00000000000000000001 /* SecretStorePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretStorePlugin.swift; sourceTree = ""; }; + SK0S00000000000000000003 /* SecretStorePluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretStorePluginTests.swift; sourceTree = ""; }; AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = ""; }; AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = ""; }; AA0000000000000000000H01 /* NetworkPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPlugin.swift; sourceTree = ""; }; @@ -429,6 +433,7 @@ 27CACC7F305C67530046F79C /* WidgetLocationCatalogTests.swift */, NL0G00000000000000000003 /* NativeLogTests.swift */, LR0S00000000000000000003 /* LocationReportSenderTests.swift */, + SK0S00000000000000000003 /* SecretStorePluginTests.swift */, NSE000000000000000000011 /* NotificationDeliveryBreadcrumbTests.swift */, 270FCEA4305CD2D6003D1E62 /* CurrentWeatherSnapshotAddressTests.swift */, 270FCEF4305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift */, @@ -497,6 +502,7 @@ AA0000000000000000000C01 /* CompassPlugin.swift */, AA0000000000000000000H01 /* NetworkPlugin.swift */, AA0000000000000000000E01 /* ScreenWakePlugin.swift */, + SK0S00000000000000000001 /* SecretStorePlugin.swift */, AA0000000000000000000G01 /* LocationTrackStore.swift */, AA0000000000000000000F01 /* ApnsTokenPlugin.swift */, AA0000000000000000000D01 /* DeviceInfoPlugin.swift */, @@ -843,6 +849,7 @@ 27CACC80305C67530046F79C /* WidgetLocationCatalogTests.swift in Sources */, NL0G00000000000000000004 /* NativeLogTests.swift in Sources */, LR0S00000000000000000004 /* LocationReportSenderTests.swift in Sources */, + SK0S00000000000000000004 /* SecretStorePluginTests.swift in Sources */, NSE000000000000000000012 /* NotificationDeliveryBreadcrumbTests.swift in Sources */, 270FCEF5305D1EB2003D1E62 /* WidgetSnapshotFileTests.swift in Sources */, 27CACC3E305C64AA0046F79C /* WidgetLocationIntentTests.swift in Sources */, @@ -865,6 +872,7 @@ AA0000000000000000000C02 /* CompassPlugin.swift in Sources */, AA0000000000000000000H02 /* NetworkPlugin.swift in Sources */, AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */, + SK0S00000000000000000002 /* SecretStorePlugin.swift in Sources */, AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */, AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */, AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 3b6a9b6c1..49dfc1444 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -56,6 +56,7 @@ import UserNotifications MapCachePlugin.register(with: registry.registrar(forPlugin: "MapCachePlugin")!) StorageScanPlugin.register(with: registry.registrar(forPlugin: "StorageScanPlugin")!) ScreenWakePlugin.register(with: registry.registrar(forPlugin: "ScreenWakePlugin")!) + SecretStorePlugin.register(with: registry.registrar(forPlugin: "SecretStorePlugin")!) ApnsTokenPlugin.register(with: registry.registrar(forPlugin: "ApnsTokenPlugin")!) BackgroundLocationPlugin.register( with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!) diff --git a/ios/Runner/SecretStorePlugin.swift b/ios/Runner/SecretStorePlugin.swift new file mode 100644 index 000000000..392797bec --- /dev/null +++ b/ios/Runner/SecretStorePlugin.swift @@ -0,0 +1,108 @@ +import Flutter +import Foundation +import Security + +/// Secrets the user hands the app — today the AI chat's API key. +/// +/// One Keychain generic-password item per secret, readable after the first +/// unlock and **only on this device**: `ThisDeviceOnly` keeps the item out of +/// any backup restored onto another phone, which is what lets the app say the +/// key never leaves this one. +/// +/// Errors report the `OSStatus` only, never the value. +public class SecretStorePlugin: NSObject, FlutterPlugin { + static let service = "com.exptech.dpip.secret-store" + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.exptech.dpip/secret_store", + binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(SecretStorePlugin(), channel: channel) + } + + public func handle( + _ call: FlutterMethodCall, result: @escaping FlutterResult + ) { + guard let args = call.arguments as? [String: Any], + let key = args["key"] as? String, !key.isEmpty + else { + result(FlutterError(code: "bad_key", message: "missing key", details: nil)) + return + } + switch call.method { + case "read": + switch SecretStorePlugin.read(key) { + case .success(let value): result(value) + case .failure(let status): result(SecretStorePlugin.error(status)) + } + case "write": + guard let value = args["value"] as? String else { + result(FlutterError(code: "bad_value", message: "missing value", details: nil)) + return + } + let status = SecretStorePlugin.write(key, value) + result(status == errSecSuccess ? nil : SecretStorePlugin.error(status)) + case "delete": + let status = SecretStorePlugin.delete(key) + result(status == errSecSuccess ? nil : SecretStorePlugin.error(status)) + default: + result(FlutterMethodNotImplemented) + } + } + + static func base(_ key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } + + /// The saved value, nil when there is none. + static func read(_ key: String) -> Result { + var query = base(key) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let data = item as? Data else { return .success(nil) } + return .success(String(data: data, encoding: .utf8)) + case errSecItemNotFound: + return .success(nil) + default: + return .failure(OSStatusError(status: status)) + } + } + + /// Replaces any earlier value. Delete-then-add rather than update, so the + /// accessibility class is always the one set here, even for an item an + /// older build might have written differently. + static func write(_ key: String, _ value: String) -> OSStatus { + _ = delete(key) + var item = base(key) + item[kSecValueData as String] = Data(value.utf8) + item[kSecAttrAccessible as String] = + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + return SecItemAdd(item as CFDictionary, nil) + } + + /// Removes the value; absent already counts as success. + static func delete(_ key: String) -> OSStatus { + let status = SecItemDelete(base(key) as CFDictionary) + return status == errSecItemNotFound ? errSecSuccess : status + } + + static func error(_ status: OSStatus) -> FlutterError { + FlutterError(code: "keychain", message: "OSStatus \(status)", details: nil) + } + + static func error(_ failure: OSStatusError) -> FlutterError { + error(failure.status) + } +} + +struct OSStatusError: Error { + let status: OSStatus +} diff --git a/ios/RunnerTests/SecretStorePluginTests.swift b/ios/RunnerTests/SecretStorePluginTests.swift new file mode 100644 index 000000000..1b75d57a2 --- /dev/null +++ b/ios/RunnerTests/SecretStorePluginTests.swift @@ -0,0 +1,66 @@ +import Security +import XCTest + +@testable import Runner + +final class SecretStorePluginTests: XCTestCase { + private var key: String! + + override func setUpWithError() throws { + try super.setUpWithError() + key = "test.\(UUID().uuidString)" + // tool/dev/test_ios.sh (and so CI) builds with CODE_SIGNING_ALLOWED=NO, + // and an unsigned test host has no keychain entitlement: every call then + // fails with errSecMissingEntitlement, which says nothing about this + // code. A signed run (`tool/dev/test_ios.sh CODE_SIGNING_ALLOWED=YES + // CODE_SIGN_IDENTITY=-`) exercises it for real. + let probe = SecretStorePlugin.write(key, "probe") + if probe == errSecMissingEntitlement { + throw XCTSkip("Keychain needs a signed test host (OSStatus \(probe))") + } + _ = SecretStorePlugin.delete(key) + } + + override func tearDown() { + _ = SecretStorePlugin.delete(key) + key = nil + super.tearDown() + } + + func testReadOfAnAbsentSecretIsNil() throws { + XCTAssertNil(try SecretStorePlugin.read(key).get()) + } + + func testWriteThenReadReturnsTheValue() throws { + XCTAssertEqual(SecretStorePlugin.write(key, "sk-first"), errSecSuccess) + XCTAssertEqual(try SecretStorePlugin.read(key).get(), "sk-first") + } + + func testWriteReplacesTheEarlierValue() throws { + XCTAssertEqual(SecretStorePlugin.write(key, "sk-first"), errSecSuccess) + XCTAssertEqual(SecretStorePlugin.write(key, "sk-second"), errSecSuccess) + XCTAssertEqual(try SecretStorePlugin.read(key).get(), "sk-second") + } + + func testDeleteRemovesTheValueAndAbsentDeleteSucceeds() throws { + XCTAssertEqual(SecretStorePlugin.write(key, "sk-first"), errSecSuccess) + XCTAssertEqual(SecretStorePlugin.delete(key), errSecSuccess) + XCTAssertNil(try SecretStorePlugin.read(key).get()) + XCTAssertEqual(SecretStorePlugin.delete(key), errSecSuccess) + } + + /// The promise the explanation page makes: a backup restored onto another + /// phone does not carry the key. + func testTheItemIsBoundToThisDevice() throws { + XCTAssertEqual(SecretStorePlugin.write(key, "sk-first"), errSecSuccess) + var query = SecretStorePlugin.base(key) + query[kSecReturnAttributes as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + XCTAssertEqual(SecItemCopyMatching(query as CFDictionary, &item), errSecSuccess) + let attributes = try XCTUnwrap(item as? [String: Any]) + XCTAssertEqual( + attributes[kSecAttrAccessible as String] as? String, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + } +} diff --git a/lib/core/platform/secret_store.dart b/lib/core/platform/secret_store.dart new file mode 100644 index 000000000..4f66a4b3d --- /dev/null +++ b/lib/core/platform/secret_store.dart @@ -0,0 +1,104 @@ +/// Secrets the app holds for the user — today one: the API key the user gives +/// the AI chat for their own AI service. +/// +/// A platform channel rather than a package, like the rest of `core/platform/`, +/// and deliberately not [SettingsStore]: settings live in `dpip.db` in +/// application-support, which iOS copies into iCloud and device backups. A key +/// the explanation page says never leaves this phone cannot live there. +/// +/// - **iOS:** a Keychain generic-password item with +/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, so a backup restored +/// onto another device does not carry it. +/// - **Android:** an AES-GCM key generated inside the Android Keystore (it +/// never leaves the secure hardware); only the ciphertext is written, to +/// `noBackupFilesDir`. The manifest already sets `allowBackup="false"`. +/// +/// Reading a secret the platform can no longer decrypt (a Keystore key wiped +/// by a lock-screen reset) answers null, as if it had never been saved — the +/// user enters it again; nothing else can be done with it. +library; + +import 'package:dpip/core/logging/log.dart'; +import 'package:flutter/services.dart'; + +/// The secrets the app may store. An enum, not a `String`, for the same +/// reason [SettingKey] is typed: an ad-hoc name cannot reach the store. +/// +/// **Never change a [name]** — it is the storage address of a saved secret. +enum SecretKey { + /// The API key for the AI chat's AI service. + aiApiKey('ai.apiKey'); + + const SecretKey(this.name); + + final String name; +} + +/// Reads and writes [SecretKey]s. +abstract class SecretStore { + /// The saved value, or null when there is none (or it cannot be read). + Future read(SecretKey key); + + /// Saves [value], replacing any earlier one. Throws when the platform + /// refuses, so the caller can say the key was not saved. + Future write(SecretKey key, String value); + + /// Removes the value; a no-op when there is none. + Future delete(SecretKey key); +} + +/// [SecretStore] backed by the platform's own key storage. +class PlatformSecretStore implements SecretStore { + PlatformSecretStore({MethodChannel? channel}) + : _channel = + channel ?? const MethodChannel('com.exptech.dpip/secret_store'); + + final MethodChannel _channel; + + @override + Future read(SecretKey key) async { + try { + return await _channel.invokeMethod('read', {'key': key.name}); + } on PlatformException catch (error) { + // The message names the failing step, never the value. + Log.warning('secret store: read ${key.name} failed (${error.code})'); + return null; + } on MissingPluginException { + return null; + } + } + + @override + Future write(SecretKey key, String value) async { + try { + await _channel.invokeMethod('write', { + 'key': key.name, + 'value': value, + }); + } on PlatformException catch (error) { + // Code and message name the failing step (an OSStatus, a Keystore + // exception's class), never the value. + Log.warning( + 'secret store: write ${key.name} failed ' + '(${error.code}: ${error.message})', + ); + rethrow; + } on MissingPluginException { + // A hot restart onto a build without this channel; only a full rebuild + // brings the platform side in. + Log.warning('secret store: write ${key.name} failed (no platform side)'); + rethrow; + } + } + + @override + Future delete(SecretKey key) async { + try { + await _channel.invokeMethod('delete', {'key': key.name}); + } on PlatformException catch (error) { + Log.warning('secret store: delete ${key.name} failed (${error.code})'); + } on MissingPluginException { + // Nothing was ever stored where there is no store. + } + } +} diff --git a/test/core/platform/secret_store_test.dart b/test/core/platform/secret_store_test.dart new file mode 100644 index 000000000..6cfd62e1b --- /dev/null +++ b/test/core/platform/secret_store_test.dart @@ -0,0 +1,64 @@ +import 'package:dpip/core/platform/secret_store.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.exptech.dpip/secret_store.test'); + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(() => calls.clear()); + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + void answer(Object? Function(MethodCall call) handler) => + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return handler(call); + }); + + test('names the secret, and sends the value only to write', () async { + final store = PlatformSecretStore(channel: channel); + answer((call) => call.method == 'read' ? 'sk-1' : null); + + expect(await store.read(SecretKey.aiApiKey), 'sk-1'); + await store.write(SecretKey.aiApiKey, 'sk-2'); + await store.delete(SecretKey.aiApiKey); + + expect(calls.map((c) => c.method), ['read', 'write', 'delete']); + expect(calls[0].arguments, {'key': 'ai.apiKey'}); + expect(calls[1].arguments, {'key': 'ai.apiKey', 'value': 'sk-2'}); + expect(calls[2].arguments, {'key': 'ai.apiKey'}); + }); + + test('an unreadable secret reads as absent', () async { + answer((_) => throw PlatformException(code: 'failed')); + expect( + await PlatformSecretStore(channel: channel).read(SecretKey.aiApiKey), + isNull, + ); + }); + + test('a refused write is reported to the caller', () async { + answer((_) => throw PlatformException(code: 'keychain')); + await expectLater( + PlatformSecretStore(channel: channel).write(SecretKey.aiApiKey, 'k'), + throwsA(isA()), + ); + }); + + test( + 'without a platform side, nothing is stored and nothing breaks', + () async { + final store = PlatformSecretStore(channel: channel); + expect(await store.read(SecretKey.aiApiKey), isNull); + await store.delete(SecretKey.aiApiKey); + // A write must not look saved when nothing could store it. + await expectLater( + store.write(SecretKey.aiApiKey, 'k'), + throwsA(isA()), + ); + }, + ); +} From 024baf92595fadb4160f102953a23140fc4ce27a Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 23 Oct 2026 05:18:01 +0800 Subject: [PATCH 2/3] feat(ai): add an AI assistant that answers from DPIP's data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: all New(zh-Hant): 首頁右上角新增 AI 助理:用你自己的 AI 服務問地震、天氣、颱風等,AI 會先查 DPIP 的即時資料再回答;金鑰只存在這支手機,對話不會儲存 New(en-US): a new AI assistant, opened from the top right of Home, answers questions about earthquakes, weather and typhoons with your own AI service, looking up DPIP's live data first; your key stays on this phone and conversations are not saved --- lib/app/router/app_router.dart | 12 + lib/bootstrap.dart | 2 + lib/core/network/api_client.dart | 32 + lib/core/network/api_paths.dart | 9 + lib/core/settings/setting_keys.dart | 14 + lib/features/ai_chat/ai_chat_providers.dart | 56 + lib/features/ai_chat/data/ai_http.dart | 90 ++ .../data/ai_settings_repository_impl.dart | 101 ++ .../ai_chat/data/dpip_mcp_tool_host.dart | 221 +++ .../ai_chat/data/openai_chat_model.dart | 310 ++++ lib/features/ai_chat/data/think_filter.dart | 67 + .../ai_chat/domain/ai_connection.dart | 81 + lib/features/ai_chat/domain/ai_failure.dart | 53 + .../domain/ai_settings_repository.dart | 28 + lib/features/ai_chat/domain/chat_agent.dart | 292 ++++ lib/features/ai_chat/domain/chat_message.dart | 61 + lib/features/ai_chat/domain/chat_model.dart | 40 + .../ai_chat/domain/system_prompt.dart | 107 ++ lib/features/ai_chat/domain/tool_host.dart | 44 + .../presentation/ai_chat_controller.dart | 245 +++ .../ai_chat/presentation/ai_setup.dart | 63 + .../ai_chat/presentation/ai_texts.dart | 55 + .../presentation/pages/ai_chat_page.dart | 399 +++++ .../presentation/pages/ai_settings_page.dart | 366 +++++ .../presentation/widgets/ai_intro.dart | 102 ++ .../presentation/widgets/ai_message_view.dart | 335 ++++ .../presentation/widgets/mcp_url_card.dart | 80 + .../home/presentation/pages/home_page.dart | 10 + lib/l10n/app_en.arb | 184 ++- lib/l10n/app_fil.arb | 93 +- lib/l10n/app_id.arb | 93 +- lib/l10n/app_ja.arb | 93 +- lib/l10n/app_ko.arb | 93 +- lib/l10n/app_th.arb | 93 +- lib/l10n/app_vi.arb | 93 +- lib/l10n/app_yue.arb | 93 +- lib/l10n/app_zh.arb | 93 +- lib/l10n/app_zh_Hans.arb | 93 +- lib/l10n/app_zh_Hant_HK.arb | 93 +- lib/l10n/app_zh_TW.arb | 93 +- lib/l10n/gen/app_localizations.dart | 546 +++++++ lib/l10n/gen/app_localizations_en.dart | 308 ++++ lib/l10n/gen/app_localizations_fil.dart | 312 ++++ lib/l10n/gen/app_localizations_id.dart | 308 ++++ lib/l10n/gen/app_localizations_ja.dart | 297 ++++ lib/l10n/gen/app_localizations_ko.dart | 298 ++++ lib/l10n/gen/app_localizations_th.dart | 309 ++++ lib/l10n/gen/app_localizations_vi.dart | 308 ++++ lib/l10n/gen/app_localizations_yue.dart | 290 ++++ lib/l10n/gen/app_localizations_zh.dart | 1430 +++++++++++++++-- lib/shared/navigation/app_routes.dart | 8 + lib/shared/widgets/region_bar.dart | 99 +- test/app/router/app_router_test.dart | 14 + test/features/ai_chat/data/ai_http_test.dart | 50 + .../ai_settings_repository_impl_test.dart | 130 ++ .../ai_chat/data/dpip_mcp_tool_host_test.dart | 205 +++ .../ai_chat/data/openai_chat_model_test.dart | 325 ++++ .../ai_chat/data/think_filter_test.dart | 37 + .../ai_chat/domain/ai_connection_test.dart | 55 + .../ai_chat/domain/chat_agent_test.dart | 271 ++++ .../ai_chat/domain/system_prompt_test.dart | 44 + test/features/ai_chat/fakes.dart | 219 +++ .../presentation/ai_chat_controller_test.dart | 203 +++ .../presentation/ai_chat_layout_test.dart | 77 + .../ai_chat/presentation/ai_pages_test.dart | 177 ++ .../earthquake/data/earthquake_api_test.dart | 10 + .../earthquake/replay_session_test.dart | 10 + .../presentation/pages/home_page_test.dart | 17 + test/shared/widgets/region_chrome_test.dart | 57 + 69 files changed, 10724 insertions(+), 172 deletions(-) create mode 100644 lib/features/ai_chat/ai_chat_providers.dart create mode 100644 lib/features/ai_chat/data/ai_http.dart create mode 100644 lib/features/ai_chat/data/ai_settings_repository_impl.dart create mode 100644 lib/features/ai_chat/data/dpip_mcp_tool_host.dart create mode 100644 lib/features/ai_chat/data/openai_chat_model.dart create mode 100644 lib/features/ai_chat/data/think_filter.dart create mode 100644 lib/features/ai_chat/domain/ai_connection.dart create mode 100644 lib/features/ai_chat/domain/ai_failure.dart create mode 100644 lib/features/ai_chat/domain/ai_settings_repository.dart create mode 100644 lib/features/ai_chat/domain/chat_agent.dart create mode 100644 lib/features/ai_chat/domain/chat_message.dart create mode 100644 lib/features/ai_chat/domain/chat_model.dart create mode 100644 lib/features/ai_chat/domain/system_prompt.dart create mode 100644 lib/features/ai_chat/domain/tool_host.dart create mode 100644 lib/features/ai_chat/presentation/ai_chat_controller.dart create mode 100644 lib/features/ai_chat/presentation/ai_setup.dart create mode 100644 lib/features/ai_chat/presentation/ai_texts.dart create mode 100644 lib/features/ai_chat/presentation/pages/ai_chat_page.dart create mode 100644 lib/features/ai_chat/presentation/pages/ai_settings_page.dart create mode 100644 lib/features/ai_chat/presentation/widgets/ai_intro.dart create mode 100644 lib/features/ai_chat/presentation/widgets/ai_message_view.dart create mode 100644 lib/features/ai_chat/presentation/widgets/mcp_url_card.dart create mode 100644 test/features/ai_chat/data/ai_http_test.dart create mode 100644 test/features/ai_chat/data/ai_settings_repository_impl_test.dart create mode 100644 test/features/ai_chat/data/dpip_mcp_tool_host_test.dart create mode 100644 test/features/ai_chat/data/openai_chat_model_test.dart create mode 100644 test/features/ai_chat/data/think_filter_test.dart create mode 100644 test/features/ai_chat/domain/ai_connection_test.dart create mode 100644 test/features/ai_chat/domain/chat_agent_test.dart create mode 100644 test/features/ai_chat/domain/system_prompt_test.dart create mode 100644 test/features/ai_chat/fakes.dart create mode 100644 test/features/ai_chat/presentation/ai_chat_controller_test.dart create mode 100644 test/features/ai_chat/presentation/ai_chat_layout_test.dart create mode 100644 test/features/ai_chat/presentation/ai_pages_test.dart diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 2fa4a740b..922b05295 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -1,4 +1,6 @@ import 'package:dpip/app/shell/main_shell.dart'; +import 'package:dpip/features/ai_chat/presentation/pages/ai_chat_page.dart'; +import 'package:dpip/features/ai_chat/presentation/pages/ai_settings_page.dart'; import 'package:dpip/app/router/route_params.dart'; import 'package:dpip/app/router/widget_location_routes.dart'; import 'package:dpip/core/settings/onboarding_store.dart'; @@ -299,6 +301,16 @@ final GoRouter appRouter = GoRouter( ), ], ), + GoRoute( + path: AppRoutes.aiPath, + name: AppRoutes.ai, + builder: (_, _) => const AiChatPage(), + ), + GoRoute( + path: AppRoutes.aiSettingsPath, + name: AppRoutes.aiSettings, + builder: (_, _) => const AiSettingsPage(), + ), GoRoute( path: AppRoutes.sponsorPath, name: AppRoutes.sponsor, diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index d304cd58f..2b82fc157 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -72,6 +72,7 @@ import 'package:dpip/core/settings/default_map_layer_controller.dart'; import 'package:dpip/core/settings/color_vision_controller.dart'; import 'package:dpip/core/settings/display_settings.dart'; import 'package:dpip/core/settings/theme_controller.dart'; +import 'package:dpip/features/ai_chat/ai_chat_providers.dart'; import 'package:dpip/features/changelog/changelog_providers.dart'; import 'package:dpip/features/release_highlights/release_highlights_providers.dart'; import 'package:dpip/features/disaster_map/disaster_map_providers.dart'; @@ -527,6 +528,7 @@ Future bootstrap() async { ...homeProviders(), ...statusProviders(deps), ...bugTrackerProviders(deps), + ...aiChatProviders(deps), ], ), ); diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index b6c0ce457..b2b6df55d 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -131,6 +131,38 @@ class ApiClient { return response.data; } + /// Absolute-URL request with a streamed body (no region failover) — a + /// third-party host that writes its answer as it goes, e.g. the AI service + /// the user set up for the AI chat. + /// + /// Never cached: [EtagInterceptor] skips stream responses, which is the + /// point for an answer that depends on the caller's own key. Every status + /// is returned rather than thrown, so the caller can read the service's own + /// error body (an AI service explains a 400 or a 429 there). [idleTimeout] + /// replaces the client-wide 10 s receive timeout: Dio applies it between + /// body chunks, and a model on a busy GPU can think for longer than that + /// before its first word. `Accept-Encoding: identity`, as for SSE: a + /// compressed body arrives in the compressor's blocks, not as written. + Future> streamAbsolute( + String url, { + String method = 'POST', + Object? data, + Map? headers, + Duration idleTimeout = Duration.zero, + CancelToken? cancelToken, + }) => _dio.request( + url, + data: data, + cancelToken: cancelToken, + options: Options( + method: method, + responseType: ResponseType.stream, + receiveTimeout: idleTimeout, + headers: {'Accept-Encoding': 'identity', ...?headers}, + validateStatus: (_) => true, + ), + ); + static BytePayload _bytePayload(Response response) { final data = response.data; final Uint8List bytes = switch (data) { diff --git a/lib/core/network/api_paths.dart b/lib/core/network/api_paths.dart index 862600cf1..624e482fe 100644 --- a/lib/core/network/api_paths.dart +++ b/lib/core/network/api_paths.dart @@ -48,4 +48,13 @@ abstract final class ApiPaths { /// CWA tsunami bulletins — the index, and `/{id}` for one bulletin. static const String tsunami = '/api/v1/cwa/tsunami'; + + /// The read-only DPIP MCP server (Streamable HTTP, JSON-RPC over POST) that + /// the AI chat hands its AI service as tools. + static const String mcp = '/mcp'; + + /// [mcp] on the DNS-balanced bare host — the address the AI chat *shows* + /// users for their own MCP clients. The app itself never calls it (see + /// `api.md`); it goes through the region-pinned hosts like every other call. + static const String mcpPublicUrl = 'https://api.core.exptech.dev/mcp'; } diff --git a/lib/core/settings/setting_keys.dart b/lib/core/settings/setting_keys.dart index ab59bec7e..4c6254690 100644 --- a/lib/core/settings/setting_keys.dart +++ b/lib/core/settings/setting_keys.dart @@ -283,4 +283,18 @@ abstract final class SettingKeys { static const SettingKey regionCore = SettingKey._( 'network:region:core', ); + + /// The AI chat's AI service: its OpenAI-compatible base URL and the model + /// to ask for. The API key is **not** a setting — it lives in the platform's + /// key storage (`SecretKey.aiApiKey`), because settings are backed up. See + /// `AiSettingsRepositoryImpl`. + static const SettingKey aiBaseUrl = SettingKey._( + 'ai.baseUrl', + ); + static const SettingKey aiModel = SettingKey._('ai.model'); + + /// Whether the AI chat's explanation page has been read and dismissed. + static const SettingKey aiIntroSeen = SettingKey._( + 'ai.introSeen', + ); } diff --git a/lib/features/ai_chat/ai_chat_providers.dart b/lib/features/ai_chat/ai_chat_providers.dart new file mode 100644 index 000000000..12095408d --- /dev/null +++ b/lib/features/ai_chat/ai_chat_providers.dart @@ -0,0 +1,56 @@ +/// AI chat feature providers. +library; + +import 'package:dpip/core/di/shared_deps.dart'; +import 'package:dpip/core/platform/secret_store.dart'; +import 'package:dpip/core/realtime/app_time.dart'; +import 'package:dpip/features/ai_chat/data/ai_settings_repository_impl.dart'; +import 'package:dpip/features/ai_chat/data/dpip_mcp_tool_host.dart'; +import 'package:dpip/features/ai_chat/data/openai_chat_model.dart'; +import 'package:dpip/features/ai_chat/domain/system_prompt.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_chat_controller.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_setup.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +/// The AI assistant's setup and its one in-memory conversation. +/// +/// Both are created on first use — opening the AI page — so a user who never +/// does pays for neither, and no key-storage read happens at launch. +List aiChatProviders(SharedDeps deps) { + final settings = AiSettingsRepositoryImpl( + deps.settings, + PlatformSecretStore(), + deps.apiClient, + ); + final tools = DpipMcpToolHost(deps.apiClient); + return [ + ChangeNotifierProvider(create: (_) => AiSetup(settings)), + ChangeNotifierProvider( + create: (_) => AiChatController( + settings: settings, + models: (connection, key) => + OpenAiChatModel(deps.apiClient, connection, key), + tools: tools, + places: () => _places(deps), + taipeiNow: () => AppTime.utc8, + ), + ), + ]; +} + +/// The user's townships, by name and code only — the explanation page says +/// no coordinates are sent, and none are. +List _places(SharedDeps deps) { + final regions = deps.regionStore; + final towns = deps.townDirectory; + final current = regions.currentCode; + return [ + if (towns.byCode(current) case final town?) + PromptPlace(name: town.fullName, code: current!, current: true), + for (final code in regions.savedCodes) + if (code != current) + if (towns.byCode(code) case final town?) + PromptPlace(name: town.fullName, code: code, current: false), + ]; +} diff --git a/lib/features/ai_chat/data/ai_http.dart b/lib/features/ai_chat/data/ai_http.dart new file mode 100644 index 000000000..3bbb3788b --- /dev/null +++ b/lib/features/ai_chat/data/ai_http.dart @@ -0,0 +1,90 @@ +/// HTTP plumbing shared by the AI service's calls: reading a body, and turning +/// its failures into [AiException]s. +/// +/// Nothing here logs a URL, a key or a request body: the log can leave the +/// phone in a diagnostics report, and the page promises the setup does not. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; + +/// [body] as text, reading at most [limit] bytes of it. +Future readText(Stream body, int limit) async { + final bytes = BytesBuilder(copy: false); + await for (final chunk in body) { + bytes.add(chunk); + if (bytes.length >= limit) break; + } + return utf8.decode(bytes.takeBytes(), allowMalformed: true); +} + +/// The failure for an HTTP error [status], with the service's explanation +/// pulled out of [body] when it has one. +AiException aiExceptionForStatus(int status, String body) { + final kind = switch (status) { + 401 || 403 => AiFailureKind.unauthorized, + 404 => AiFailureKind.notFound, + 429 => AiFailureKind.rateLimited, + 408 => AiFailureKind.timeout, + >= 500 => AiFailureKind.server, + _ => AiFailureKind.rejected, + }; + return AiException(kind, status: status, detail: errorDetail(body)); +} + +/// The failure for a request that did not get an HTTP answer, or null for a +/// cancellation (which is not a failure: the user pressed stop). +AiException? aiExceptionForDio(DioException error) => switch (error.type) { + DioExceptionType.cancel => null, + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => const AiException(AiFailureKind.timeout), + DioExceptionType.badResponse => aiExceptionForStatus( + error.response?.statusCode ?? 0, + switch (error.response?.data) { + final String text => text, + final Object? data => data == null ? '' : _tryEncode(data), + }, + ), + _ => const AiException(AiFailureKind.network), +}; + +/// The human-readable reason in an error body, in the shapes the common +/// services use: `{"error": {"message"}}` (OpenAI and most others), +/// `{"error": "…"}`, `{"message"}`, `{"detail"}`, and Gemini's one-element +/// list. A body that is not JSON is used as-is when it is short text. +String? errorDetail(String body) { + final text = body.trim(); + if (text.isEmpty) return null; + Object? json; + try { + json = jsonDecode(text); + } on FormatException { + return text.startsWith('<') ? null : _clip(text); + } + if (json is List && json.isNotEmpty) json = json.first; + if (json is! Map) return null; + final error = json['error']; + final message = switch (error) { + final Map e => e['message'], + final String e => e, + _ => json['message'] ?? json['detail'], + }; + return message is String && message.trim().isNotEmpty + ? _clip(message.trim()) + : null; +} + +String _clip(String text) => + text.length <= 300 ? text : '${text.substring(0, 300)}…'; + +String _tryEncode(Object data) { + try { + return jsonEncode(data); + } on Object { + return ''; + } +} diff --git a/lib/features/ai_chat/data/ai_settings_repository_impl.dart b/lib/features/ai_chat/data/ai_settings_repository_impl.dart new file mode 100644 index 000000000..cd27b15ff --- /dev/null +++ b/lib/features/ai_chat/data/ai_settings_repository_impl.dart @@ -0,0 +1,101 @@ +/// The AI chat's setup, kept on this phone. +/// +/// The base URL and model are ordinary settings. The key is not: it goes to +/// the platform's key storage ([SecretStore]), which a backup restored onto +/// another phone does not carry. +library; + +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/platform/secret_store.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/features/ai_chat/data/ai_http.dart'; +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/ai_settings_repository.dart'; + +class AiSettingsRepositoryImpl implements AiSettingsRepository { + AiSettingsRepositoryImpl(this._settings, this._secrets, this._api); + + final SettingsStore _settings; + final SecretStore _secrets; + final ApiClient _api; + + @override + bool get introSeen => _settings.getBool(SettingKeys.aiIntroSeen) ?? false; + + @override + Future markIntroSeen() => + _settings.setBool(SettingKeys.aiIntroSeen, true); + + @override + AiConnection get connection => AiConnection( + baseUrl: _settings.getString(SettingKeys.aiBaseUrl) ?? '', + model: _settings.getString(SettingKeys.aiModel) ?? '', + ); + + @override + Future apiKey() => _secrets.read(SecretKey.aiApiKey); + + @override + Future save(AiConnection connection, {String? apiKey}) async { + // The key first: if the platform refuses it, nothing else changes and + // the page can say so. + if (apiKey != null) { + final key = apiKey.trim(); + key.isEmpty + ? await _secrets.delete(SecretKey.aiApiKey) + : await _secrets.write(SecretKey.aiApiKey, key); + } + await _settings.setString(SettingKeys.aiBaseUrl, connection.baseUrl); + await _settings.setString(SettingKeys.aiModel, connection.model); + } + + @override + Future forget() async { + await _secrets.delete(SecretKey.aiApiKey); + await _settings.remove(SettingKeys.aiBaseUrl); + await _settings.remove(SettingKeys.aiModel); + } + + @override + Future> listModels(String baseUrl, String? apiKey) async { + final key = apiKey?.trim(); + final Object? json; + try { + // Streamed, so the answer is never cached: it depends on the key. + final response = await _api.streamAbsolute( + '$baseUrl/models', + method: 'GET', + headers: { + if (key != null && key.isNotEmpty) 'Authorization': 'Bearer $key', + }, + idleTimeout: const Duration(seconds: 15), + ); + final status = response.statusCode ?? 0; + final text = await readText(response.data!.stream, 4 * 1024 * 1024); + if (status < 200 || status >= 300) { + throw aiExceptionForStatus(status, text); + } + json = jsonDecode(text); + } on DioException catch (error) { + throw aiExceptionForDio(error) ?? + const AiException(AiFailureKind.network); + } on FormatException { + throw const AiException(AiFailureKind.badResponse); + } + final data = json is Map ? json['data'] ?? json['models'] : json; + if (data is! List) throw const AiException(AiFailureKind.badResponse); + final ids = { + for (final entry in data) + if (entry is Map && entry['id'] is String) + entry['id'] as String + else if (entry is Map && entry['name'] is String) + entry['name'] as String, + }.toList()..sort(); + return ids; + } +} diff --git a/lib/features/ai_chat/data/dpip_mcp_tool_host.dart b/lib/features/ai_chat/data/dpip_mcp_tool_host.dart new file mode 100644 index 000000000..be7ecc8bf --- /dev/null +++ b/lib/features/ai_chat/data/dpip_mcp_tool_host.dart @@ -0,0 +1,221 @@ +/// The DPIP MCP server as the AI chat's [ToolHost]. +/// +/// MCP over Streamable HTTP is JSON-RPC 2.0 in a POST; the answer comes back +/// either as JSON or as a short event stream carrying the same JSON. Only the +/// three methods a client of read-only tools needs are spoken: `initialize` +/// (for the server's usage notes), `tools/list`, and `tools/call`. +/// +/// Calls go through the region-aware [ApiClient] on [ApiTier.coreApi], like +/// every other DPIP request, so a region that is down fails over. The server +/// is stateless, so a failed-over call needs no new session; if it ever hands +/// out a session id, it is echoed back, and a 404 on it starts a new one. +library; + +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_paths.dart'; +import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/network/sse_client.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/tool_host.dart'; + +class DpipMcpToolHost implements ToolHost { + DpipMcpToolHost(this._api); + + final ApiClient _api; + + static const String protocolVersion = '2025-06-18'; + + ToolCatalog? _catalog; + Future? _loading; + String? _session; + int _nextId = 1; + + /// Listed once per session: the tools change only when the server is + /// deployed, and a stale list costs at most an "unknown tool" answer. + @override + Future catalog() async { + final cached = _catalog; + if (cached != null) return cached; + return _loading ??= _load().whenComplete(() => _loading = null); + } + + Future _load() async { + try { + final init = await _rpc('initialize', { + 'protocolVersion': protocolVersion, + 'capabilities': const {}, + 'clientInfo': const {'name': 'dpip', 'version': '1'}, + }); + await _notify('notifications/initialized'); + final tools = []; + String? cursor; + // Bounded: a server that keeps returning a cursor is not followed + // forever. + for (var page = 0; page < 10; page++) { + final result = await _rpc('tools/list', {'cursor': ?cursor}); + for (final tool in (result['tools'] as List? ?? const [])) { + if (tool is! Map) continue; + final name = tool['name']; + if (name is! String || name.isEmpty) continue; + final schema = tool['inputSchema']; + tools.add( + ToolSpec( + name: name, + description: (tool['description'] as String?) ?? '', + parameters: schema is Map + ? schema.cast() + : const {'type': 'object', 'properties': {}}, + ), + ); + } + cursor = result['nextCursor'] as String?; + if (cursor == null) break; + } + if (tools.isEmpty) throw const FormatException('no tools'); + final instructions = init['instructions']; + return _catalog = ToolCatalog( + tools: List.unmodifiable(tools), + instructions: instructions is String ? instructions : null, + ); + } on AiException { + rethrow; + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'ai chat: listing the DPIP tools'); + throw const AiException(AiFailureKind.toolsUnavailable); + } + } + + @override + Future call(String name, Map arguments) async { + try { + final result = await _rpc('tools/call', { + 'name': name, + 'arguments': arguments, + }); + final structured = result['structuredContent']; + final content = structured != null + ? jsonEncode(structured) + : [ + for (final part in (result['content'] as List? ?? const [])) + if (part is Map && part['type'] == 'text') part['text'], + ].join('\n'); + return ToolOutcome(content, isError: result['isError'] == true); + } on _RpcError catch (error) { + // Bad arguments and the like: the model can read this and try again. + return ToolOutcome(error.message, isError: true); + } catch (error) { + Log.warning('ai chat: tool $name failed (${error.runtimeType})'); + return const ToolOutcome( + 'The DPIP data server could not be reached.', + isError: true, + ); + } + } + + Future> _rpc( + String method, + Map params, { + bool retried = false, + }) async { + final id = _nextId++; + final Response response; + try { + response = await _post({ + 'jsonrpc': '2.0', + 'id': id, + 'method': method, + 'params': params, + }); + } on DioException catch (error) { + // An expired session: start a new one, once. Only from a tool call — + // the listing *is* the new session, and waiting on it from inside it + // would never finish. + if (!retried && + _session != null && + error.response?.statusCode == 404 && + method == 'tools/call') { + _session = null; + _catalog = null; + await catalog(); + return _rpc(method, params, retried: true); + } + rethrow; + } + final message = await _find(response, id); + final error = message['error']; + if (error is Map) { + throw _RpcError( + (error['message'] as String?) ?? 'error ${error['code']}', + ); + } + final result = message['result']; + if (result is! Map) throw const FormatException('no result'); + return result.cast(); + } + + Future _notify(String method) async { + await _post({'jsonrpc': '2.0', 'method': method}); + } + + Future> _post(Map body) async { + final response = await _api.request( + ApiTier.coreApi, + ApiPaths.mcp, + method: 'POST', + data: jsonEncode(body), + options: Options( + contentType: Headers.jsonContentType, + responseType: ResponseType.plain, + // A tool that reaches several sources (a year of reports) can take a + // few seconds; the client-wide 10 s would cut it short. + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Accept': 'application/json, text/event-stream', + 'MCP-Protocol-Version': protocolVersion, + if (_session != null) 'Mcp-Session-Id': _session, + }, + ), + ); + final session = response.headers.value('mcp-session-id'); + if (session != null && session.isNotEmpty) _session = session; + return response; + } + + /// The JSON-RPC message answering [id], from a JSON or event-stream body. + static Future> _find( + Response response, + int id, + ) async { + final body = response.data is String ? response.data as String : ''; + final type = response.headers.value(Headers.contentTypeHeader) ?? ''; + final candidates = []; + if (type.contains('text/event-stream')) { + await for (final event in HttpSseClient.parse( + Stream.value(utf8.encode(body)), + )) { + if (event.isDefault && event.data.isNotEmpty) { + candidates.add(jsonDecode(event.data)); + } + } + } else { + final json = jsonDecode(body); + json is List ? candidates.addAll(json) : candidates.add(json); + } + for (final candidate in candidates) { + if (candidate is Map && candidate['id'] == id) { + return candidate.cast(); + } + } + throw const FormatException('no answer for the request'); + } +} + +final class _RpcError implements Exception { + const _RpcError(this.message); + + final String message; +} diff --git a/lib/features/ai_chat/data/openai_chat_model.dart b/lib/features/ai_chat/data/openai_chat_model.dart new file mode 100644 index 000000000..387465fa1 --- /dev/null +++ b/lib/features/ai_chat/data/openai_chat_model.dart @@ -0,0 +1,310 @@ +/// A [ChatModel] over any OpenAI-compatible `/chat/completions` endpoint. +/// +/// That one wire format reaches OpenAI, Gemini, Anthropic, OpenRouter, Groq +/// and the common self-hosted servers (vLLM, Ollama, LM Studio, llama.cpp), +/// each with small deviations handled in [CompletionDecoder]. +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/sse_client.dart'; +import 'package:dpip/features/ai_chat/data/ai_http.dart'; +import 'package:dpip/features/ai_chat/data/think_filter.dart'; +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/chat_model.dart'; +import 'package:dpip/features/ai_chat/domain/tool_host.dart'; + +class OpenAiChatModel implements ChatModel { + OpenAiChatModel( + this._api, + this._connection, + this._apiKey, { + this.idleTimeout = const Duration(seconds: 120), + }); + + final ApiClient _api; + final AiConnection _connection; + final String? _apiKey; + + /// The longest silence tolerated mid-answer. Generous on purpose: a large + /// model on one shared GPU can queue a request, then think, before its + /// first word. + final Duration idleTimeout; + + @override + Stream complete({ + required List messages, + required List tools, + }) { + final cancel = CancelToken(); + late final StreamController out; + out = StreamController( + onListen: () => unawaited(_run(out, cancel, messages, tools)), + onCancel: () { + if (!cancel.isCancelled) cancel.cancel(); + }, + ); + return out.stream; + } + + Future _run( + StreamController out, + CancelToken cancel, + List messages, + List tools, + ) async { + try { + final key = _apiKey; + final response = await _api.streamAbsolute( + '${_connection.baseUrl}/chat/completions', + data: jsonEncode({ + 'model': _connection.model, + 'messages': [for (final m in messages) encodeMessage(m)], + if (tools.isNotEmpty) 'tools': [for (final t in tools) encodeTool(t)], + 'stream': true, + }), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream, application/json', + if (key != null && key.isNotEmpty) 'Authorization': 'Bearer $key', + }, + idleTimeout: idleTimeout, + cancelToken: cancel, + ); + final status = response.statusCode ?? 0; + final body = response.data!.stream; + if (status < 200 || status >= 300) { + throw aiExceptionForStatus(status, await readText(body, 16 * 1024)); + } + final decoder = CompletionDecoder(); + final type = response.headers.value(Headers.contentTypeHeader) ?? ''; + if (type.contains('text/event-stream')) { + await for (final event in HttpSseClient.parse(body)) { + if (!event.isDefault) continue; + final data = event.data.trim(); + if (data.isEmpty) continue; + if (data == '[DONE]') break; + decoder.chunk(jsonDecode(data)).forEach(out.add); + } + } else { + // A service that ignores `stream: true` sends one JSON body. + decoder + .chunk(jsonDecode(await readText(body, 4 * 1024 * 1024))) + .forEach(out.add); + } + final (:tail, :message) = decoder.finish(); + tail.forEach(out.add); + out.add(ModelDone(message)); + } on DioException catch (error) { + final failure = aiExceptionForDio(error); + if (failure != null) out.addError(failure); + } on AiException catch (error) { + out.addError(error); + } on FormatException { + out.addError(const AiException(AiFailureKind.badResponse)); + } finally { + // Not awaited: after a cancellation the close never completes, since + // no one is left to receive the done event. + unawaited(out.close()); + } + } + + /// One message in the wire format. + static Map encodeMessage(ChatMessage message) => + switch (message) { + SystemMessage(:final text) => {'role': 'system', 'content': text}, + UserMessage(:final text) => {'role': 'user', 'content': text}, + AssistantMessage(:final text, :final toolCalls) => { + 'role': 'assistant', + // null, not absent, beside tool calls: it is what the services + // themselves return there, so it is the form every one accepts. + 'content': text.isEmpty && toolCalls.isNotEmpty ? null : text, + if (toolCalls.isNotEmpty) + 'tool_calls': [ + for (final call in toolCalls) + { + 'id': call.id, + 'type': 'function', + 'function': { + 'name': call.name, + 'arguments': call.arguments.isEmpty ? '{}' : call.arguments, + }, + }, + ], + }, + ToolResultMessage(:final callId, :final content) => { + 'role': 'tool', + 'tool_call_id': callId, + 'content': content, + }, + }; + + /// One tool in the wire format, its schema reduced to what every service + /// accepts (see [sanitizeSchema]). + static Map encodeTool(ToolSpec tool) => { + 'type': 'function', + 'function': { + 'name': tool.name, + 'description': tool.description, + 'parameters': sanitizeSchema(tool.parameters), + }, + }; + + /// [schema] without the JSON Schema features some services reject. + /// + /// The DPIP server writes optional parameters as `"type": ["null", "integer"]` + /// and closes objects with `additionalProperties`; OpenAI takes both, but + /// Gemini's compatible endpoint refuses the whole request over either. A + /// nullable type becomes its non-null type (leaving the parameter out says + /// the same), and the extra keywords are dropped — they constrain, they do + /// not describe, and the server validates the call regardless. + static Map sanitizeSchema(Map schema) { + final out = {}; + for (final MapEntry(:key, :value) in schema.entries) { + if (key == r'$schema' || key == 'additionalProperties') continue; + out[key] = switch ((key, value)) { + ('type', final List types) => types.firstWhere( + (t) => t != 'null', + orElse: () => 'string', + ), + ('enum', final List values) => [for (final v in values) ?v], + // Keys under `properties` are parameter names, not keywords: keep + // them all, clean each parameter's own schema. + ('properties', final Map properties) => { + for (final MapEntry(:key, :value) in properties.entries) + key.toString(): _sanitizeValue(value), + }, + _ => _sanitizeValue(value), + }; + } + if (out['type'] == 'object' && !out.containsKey('properties')) { + out['properties'] = const {}; + } + return out; + } + + static Object? _sanitizeValue(Object? value) => switch (value) { + final Map map => sanitizeSchema( + map.map((k, v) => MapEntry(k.toString(), v)), + ), + final List list => [for (final v in list) _sanitizeValue(v)], + _ => value, + }; +} + +/// Assembles a streamed completion: text deltas, reasoning, and tool calls +/// that arrive in pieces. +class CompletionDecoder { + final StringBuffer _text = StringBuffer(); + final ThinkFilter _think = ThinkFilter(); + final Map _calls = {}; + + /// Events for one decoded chunk — or for a whole non-streamed response, + /// whose `message` is read like a single `delta`. + Iterable chunk(Object? json) sync* { + if (json is! Map) throw const FormatException('not an object'); + final error = json['error']; + if (error != null) { + // Some services report a failure inside a 200 stream (OpenRouter sends + // an upstream 429 this way). + final code = error is Map ? error['code'] : null; + final body = jsonEncode(json); + throw code is int && code >= 400 + ? aiExceptionForStatus(code, body) + : AiException(AiFailureKind.server, detail: errorDetail(body)); + } + final choices = json['choices']; + // A usage-only chunk, or a keep-alive some proxies insert. + if (choices is! List || choices.isEmpty) return; + final choice = choices.first; + if (choice is! Map) return; + final delta = choice['delta'] ?? choice['message']; + if (delta is! Map) return; + + final reasoning = delta['reasoning_content'] ?? delta['reasoning']; + if (reasoning is String && reasoning.isNotEmpty) { + yield const ModelThinking(); + } + final content = delta['content']; + if (content is String && content.isNotEmpty) { + final visible = _think.add(content); + if (_think.thinking) yield const ModelThinking(); + if (visible.isNotEmpty) { + _text.write(visible); + yield ModelText(visible); + } + } + final toolCalls = delta['tool_calls']; + if (toolCalls is List) { + for (final call in toolCalls) { + if (call is Map) _absorbCall(call); + } + } + } + + void _absorbCall(Map call) { + final id = call['id']; + final index = switch (call['index']) { + final int i => i, + // No index (some compatible servers): an id names the call; with + // neither, a piece continues the call before it. + _ when id is String && id.isNotEmpty => + _calls.entries + .where((e) => e.value.id == id) + .map((e) => e.key) + .firstOrNull ?? + _calls.length, + _ => _calls.isEmpty ? 0 : _calls.keys.reduce((a, b) => a > b ? a : b), + }; + final builder = _calls.putIfAbsent(index, _CallBuilder.new); + if (id is String && id.isNotEmpty) builder.id ??= id; + final function = call['function']; + if (function is! Map) return; + final name = function['name']; + // Set once: a few servers repeat the name in every piece. + if (name is String && name.isNotEmpty && builder.name.isEmpty) { + builder.name = name; + } + switch (function['arguments']) { + case final String piece: + builder.arguments.write(piece); + case final Map whole: + builder.arguments + ..clear() + ..write(jsonEncode(whole)); + } + } + + /// The text still held back, and the finished message. + ({List tail, AssistantMessage message}) finish() { + final rest = _think.flush(); + _text.write(rest); + final indices = _calls.keys.toList()..sort(); + return ( + tail: [if (rest.isNotEmpty) ModelText(rest)], + message: AssistantMessage( + text: _text.toString(), + toolCalls: [ + for (final i in indices) + if (_calls[i]!.name.isNotEmpty) + ToolCall( + id: _calls[i]!.id ?? 'call_$i', + name: _calls[i]!.name, + arguments: _calls[i]!.arguments.toString(), + ), + ], + ), + ); + } +} + +class _CallBuilder { + String? id; + String name = ''; + final StringBuffer arguments = StringBuffer(); +} diff --git a/lib/features/ai_chat/data/think_filter.dart b/lib/features/ai_chat/data/think_filter.dart new file mode 100644 index 000000000..87ffcc6b0 --- /dev/null +++ b/lib/features/ai_chat/data/think_filter.dart @@ -0,0 +1,67 @@ +/// Hides a reasoning block a model writes into its answer text. +/// +/// Reasoning models served without a reasoning parser (a self-hosted vLLM or +/// llama.cpp, typically) put their thinking in the content itself, wrapped in +/// `…` at the very start. That is not the answer, and it can +/// run to pages. Only a block that *opens* the answer is hidden; the tag +/// anywhere else is ordinary text. +library; + +enum _State { undecided, thinking, answering } + +class ThinkFilter { + static const String _open = ''; + static const String _close = ''; + + _State _state = _State.undecided; + final StringBuffer _held = StringBuffer(); + + /// Whether the last [add] was swallowed by an open reasoning block. + bool get thinking => _state == _State.thinking; + + /// The part of [delta] that is answer text — possibly empty while the + /// opening is still being decided or the model is still thinking. + String add(String delta) { + switch (_state) { + case _State.answering: + return delta; + case _State.undecided: + _held.write(delta); + final text = _held.toString().trimLeft(); + if (text.length < _open.length && _open.startsWith(text)) return ''; + _held.clear(); + if (!text.startsWith(_open)) { + _state = _State.answering; + return text; + } + _state = _State.thinking; + return _scan(text.substring(_open.length)); + case _State.thinking: + final text = _held.toString() + delta; + _held.clear(); + return _scan(text); + } + } + + /// What is still held back when the answer ends: a reply too short to have + /// been a reasoning tag is still the reply. + String flush() { + final rest = _state == _State.undecided ? _held.toString().trimLeft() : ''; + _held.clear(); + return rest; + } + + String _scan(String text) { + final end = text.indexOf(_close); + if (end == -1) { + // Keep only enough to recognise a closing tag split across deltas. + final keep = text.length < _close.length + ? text.length + : _close.length - 1; + _held.write(text.substring(text.length - keep)); + return ''; + } + _state = _State.answering; + return text.substring(end + _close.length).trimLeft(); + } +} diff --git a/lib/features/ai_chat/domain/ai_connection.dart b/lib/features/ai_chat/domain/ai_connection.dart new file mode 100644 index 000000000..a81c7efed --- /dev/null +++ b/lib/features/ai_chat/domain/ai_connection.dart @@ -0,0 +1,81 @@ +/// The user's own AI service, as the AI chat talks to it. +library; + +import 'package:dpip/features/ai_chat/domain/chat_model.dart'; + +/// An OpenAI-compatible endpoint and the model to ask there. The API key is +/// not part of it: the key never sits in a settings value, only in the +/// platform's key storage, read at the moment of a request. +final class AiConnection { + const AiConnection({required this.baseUrl, required this.model}); + + static const AiConnection empty = AiConnection(baseUrl: '', model: ''); + + /// The API root, without a trailing slash: `…/chat/completions` and + /// `…/models` are appended to it. + final String baseUrl; + final String model; + + bool get isComplete => baseUrl.isNotEmpty && model.isNotEmpty; +} + +/// Builds the model client for a connection; [apiKey] may be null for a +/// service that needs none. +typedef ChatModelFactory = ChatModel Function( + AiConnection connection, + String? apiKey, +); + +/// A well-known service, offered as a one-tap base URL. +final class AiPreset { + const AiPreset(this.name, this.baseUrl); + + /// The service's own name — a proper noun, the same in every language. + final String name; + final String baseUrl; +} + +/// Services with an OpenAI-compatible chat API that supports tool calls. +const List aiPresets = [ + AiPreset('OpenAI', 'https://api.openai.com/v1'), + AiPreset( + 'Google Gemini', + 'https://generativelanguage.googleapis.com/v1beta/openai', + ), + AiPreset('Anthropic', 'https://api.anthropic.com/v1'), + AiPreset('OpenRouter', 'https://openrouter.ai/api/v1'), + AiPreset('Groq', 'https://api.groq.com/openai/v1'), +]; + +enum BaseUrlProblem { + empty, + invalid, + + /// Plain `http://` to anything but this phone. Android refuses cleartext + /// to other hosts outright, and a key sent in the clear is a key given + /// away, so it is caught here with a reason rather than failing later + /// with an opaque connection error. + insecure, +} + +/// Cleans up what the user typed or pasted: surrounding space, trailing +/// slashes, and a pasted full `/chat/completions` endpoint all reduce to the +/// API root. +({String? url, BaseUrlProblem? problem}) normalizeBaseUrl(String input) { + var text = input.trim(); + if (text.isEmpty) return (url: null, problem: BaseUrlProblem.empty); + text = text.replaceFirst(RegExp(r'/+$'), ''); + text = text.replaceFirst(RegExp(r'/chat/completions$'), ''); + text = text.replaceFirst(RegExp(r'/+$'), ''); + final uri = Uri.tryParse(text); + if (uri == null || + !(uri.scheme == 'https' || uri.scheme == 'http') || + uri.host.isEmpty) { + return (url: null, problem: BaseUrlProblem.invalid); + } + if (uri.scheme == 'http' && + !(uri.host == 'localhost' || uri.host == '127.0.0.1')) { + return (url: null, problem: BaseUrlProblem.insecure); + } + return (url: text, problem: null); +} diff --git a/lib/features/ai_chat/domain/ai_failure.dart b/lib/features/ai_chat/domain/ai_failure.dart new file mode 100644 index 000000000..d5945a058 --- /dev/null +++ b/lib/features/ai_chat/domain/ai_failure.dart @@ -0,0 +1,53 @@ +/// Why an AI chat request failed, in the terms the page explains it in. +library; + +enum AiFailureKind { + /// 401 / 403: the key is wrong, expired, or lacks access to the model. + unauthorized, + + /// 404: the URL is not an OpenAI-compatible API, or the model does not + /// exist there. + notFound, + + /// 429: the service's rate limit or quota. + rateLimited, + + /// Another 4xx: the service refused the request; its own [AiException.detail] + /// says why (often a model that cannot call tools). + rejected, + + /// 5xx from the AI service. + server, + + /// No connection, or the connection dropped. + network, + + /// The service went quiet for longer than the chat waits. + timeout, + + /// The service answered with something that is not a chat completion. + badResponse, + + /// The DPIP data tools could not be listed. + toolsUnavailable, + + /// The model kept calling tools past the per-question limit. + tooManySteps, +} + +final class AiException implements Exception { + const AiException(this.kind, {this.status, this.detail}); + + final AiFailureKind kind; + + /// The HTTP status, when there was one. + final int? status; + + /// The service's own explanation, verbatim and untranslated (it is the + /// service's text, not the app's), trimmed to a readable length. + final String? detail; + + @override + String toString() => + 'AiException(${kind.name}${status == null ? '' : ', HTTP $status'})'; +} diff --git a/lib/features/ai_chat/domain/ai_settings_repository.dart b/lib/features/ai_chat/domain/ai_settings_repository.dart new file mode 100644 index 000000000..a2de0fd05 --- /dev/null +++ b/lib/features/ai_chat/domain/ai_settings_repository.dart @@ -0,0 +1,28 @@ +/// Where the AI chat's setup is kept: on this phone, and nowhere else. +library; + +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; + +abstract class AiSettingsRepository { + /// Whether the explanation page has been read and dismissed. + bool get introSeen; + + Future markIntroSeen(); + + /// The saved service; [AiConnection.empty] when there is none. + AiConnection get connection; + + /// The saved API key, read from the platform's key storage. + Future apiKey(); + + /// Saves [connection]. A null [apiKey] keeps the key already saved; an + /// empty one removes it (a local service may need none). Throws when the + /// key could not be stored. + Future save(AiConnection connection, {String? apiKey}); + + /// Removes the service, the model and the key. + Future forget(); + + /// The model ids [baseUrl] offers, for picking one. Throws an `AiException`. + Future> listModels(String baseUrl, String? apiKey); +} diff --git a/lib/features/ai_chat/domain/chat_agent.dart b/lib/features/ai_chat/domain/chat_agent.dart new file mode 100644 index 000000000..56c247360 --- /dev/null +++ b/lib/features/ai_chat/domain/chat_agent.dart @@ -0,0 +1,292 @@ +/// Runs one question: model, tools, model again — until the model answers. +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/chat_model.dart'; +import 'package:dpip/features/ai_chat/domain/tool_host.dart'; + +sealed class AgentEvent { + const AgentEvent(); +} + +/// More of the answer's text. +final class AgentText extends AgentEvent { + const AgentText(this.delta); + + final String delta; +} + +/// The model is reasoning before it answers. +final class AgentThinking extends AgentEvent { + const AgentThinking(); +} + +/// A tool call went out. +final class AgentToolStarted extends AgentEvent { + const AgentToolStarted(this.callId, this.name); + + final String callId; + final String name; +} + +/// A tool call came back. +final class AgentToolFinished extends AgentEvent { + const AgentToolFinished(this.callId, this.name, {required this.ok}); + + final String callId; + final String name; + final bool ok; +} + +class ChatAgent { + ChatAgent({ + required this.model, + required this.tools, + this.maxSteps = 8, + this.maxCallsPerStep = 6, + this.maxResultChars = 16000, + }); + + final ChatModel model; + final ToolHost tools; + + /// Model requests per question. Each step that calls tools costs the user + /// a request to their AI service and the DPIP server a query per call, so + /// a model stuck calling tools in a loop is stopped rather than indulged. + final int maxSteps; + + /// Tool calls honoured from a single step; the rest are answered with an + /// error telling the model to ask for fewer. + final int maxCallsPerStep; + + /// A tool result longer than this is cut, with a note saying so. A year of + /// earthquake reports would otherwise fill a small model's whole context. + final int maxResultChars; + + /// Answers the last [UserMessage] in [history]. + /// + /// Every message the turn adds is handed to [record] as it becomes final, + /// so the caller's history stays valid whenever the stream stops: an + /// assistant tool call is recorded only together with its results (a call + /// with no answer makes the next request invalid), and text cut off by a + /// cancellation is still recorded, so a follow-up question has it. + /// + /// [systemPrompt] receives the tool server's usage notes. Fails with an + /// [AiException]. + /// + /// Cancelling the subscription aborts the model request *at once* — not at + /// the model's next word, which while it thinks may be a minute away. The + /// history is final by the time `cancel` returns: the text so far is + /// recorded, tool calls already sent finish on their own and are dropped, + /// and nothing the old turn does later can land after a new question. + Stream run({ + required String Function(String? serverNotes) systemPrompt, + required List history, + required void Function(ChatMessage message) record, + }) { + late final StreamController out; + StreamSubscription? inner; + Completer? step; + var cancelled = false; + // This step's text that is not in the history yet — what a cancellation + // records, there and then. + var unsaid = StringBuffer(); + + void say(ChatMessage message) { + if (!cancelled) record(message); + } + + // Records [unsaid] once. Also reached from onCancel after a normal end + // (a closed controller calls it too), so it must clear what it records. + void sayUnsaid() { + if (unsaid.isNotEmpty) record(AssistantMessage(text: unsaid.toString())); + unsaid = StringBuffer(); + } + + Future body() async { + final catalog = await tools.catalog(); + if (cancelled) return; + final system = SystemMessage(systemPrompt(catalog.instructions)); + final names = {for (final tool in catalog.tools) tool.name}; + for (var i = 0; i < maxSteps; i++) { + unsaid = StringBuffer(); + AssistantMessage? answer; + final done = step = Completer(); + inner = model + .complete( + messages: [system, ...compact(history)], + tools: catalog.tools, + ) + .listen( + (event) { + switch (event) { + case ModelText(:final delta): + unsaid.write(delta); + out.add(AgentText(delta)); + case ModelThinking(): + out.add(const AgentThinking()); + case ModelDone(:final message): + answer = message; + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!done.isCompleted) done.completeError(error, stackTrace); + }, + onDone: () { + if (!done.isCompleted) done.complete(answer); + }, + cancelOnError: true, + ); + final AssistantMessage? message; + try { + message = await done.future; + } catch (_) { + if (!cancelled) sayUnsaid(); + rethrow; + } finally { + inner = null; + step = null; + } + if (cancelled) return; + if (message == null) { + sayUnsaid(); + throw const AiException(AiFailureKind.badResponse); + } + if (message.toolCalls.isEmpty) { + unsaid = StringBuffer(); + say(message); + return; + } + + final calls = message.toolCalls; + for (final call in calls) { + out.add(AgentToolStarted(call.id, call.name)); + } + final outcomes = await Future.wait([ + for (var c = 0; c < calls.length; c++) + c < maxCallsPerStep + ? _invoke(calls[c], names) + : Future.value( + ToolOutcome( + 'Not run: at most $maxCallsPerStep tool calls are ' + 'answered per step. Ask again with fewer.', + isError: true, + ), + ), + ]); + if (cancelled) return; + unsaid = StringBuffer(); + say(message); + for (var c = 0; c < calls.length; c++) { + say( + ToolResultMessage( + callId: calls[c].id, + name: calls[c].name, + content: outcomes[c].content, + ), + ); + } + for (var c = 0; c < calls.length; c++) { + out.add( + AgentToolFinished( + calls[c].id, + calls[c].name, + ok: !outcomes[c].isError, + ), + ); + } + } + throw const AiException(AiFailureKind.tooManySteps); + } + + out = StreamController( + onListen: () { + body() + .catchError((Object error, StackTrace stackTrace) { + if (!cancelled) out.addError(error, stackTrace); + }) + .whenComplete(() { + if (!out.isClosed) unawaited(out.close()); + }); + }, + onCancel: () { + // Synchronously, so the history is final when cancel returns: what + // was said so far goes in (without a tool call that has no answer), + // and nothing more can follow it. + if (!cancelled) sayUnsaid(); + cancelled = true; + final pending = inner; + inner = null; + final waiting = step; + if (waiting != null && !waiting.isCompleted) waiting.complete(null); + return pending?.cancel(); + }, + ); + return out.stream; + } + + Future _invoke(ToolCall call, Set names) async { + if (!names.contains(call.name)) { + return ToolOutcome( + 'There is no tool named "${call.name}".', + isError: true, + ); + } + final Map arguments; + try { + final text = call.arguments.trim(); + final decoded = text.isEmpty + ? const {} + : jsonDecode(text); + if (decoded is! Map) throw const FormatException(); + arguments = Map.from(decoded); + } on FormatException { + return const ToolOutcome( + 'The arguments were not a JSON object.', + isError: true, + ); + } + final ToolOutcome outcome; + try { + outcome = await tools.call(call.name, arguments); + } catch (_) { + return const ToolOutcome( + 'The data server could not be reached.', + isError: true, + ); + } + if (outcome.content.length <= maxResultChars) return outcome; + return ToolOutcome( + '${outcome.content.substring(0, maxResultChars)}\n' + '[Cut at $maxResultChars characters. Narrow the query (a smaller limit, ' + 'a date range, a place) for the rest.]', + isError: outcome.isError, + ); + } + + /// [history] with the results of tools called for *earlier* questions + /// replaced by a note. They have been answered already, and a long + /// conversation would otherwise resend every report it ever looked up — + /// past a small model's context, and paid for again each turn. + static List compact(List history) { + final lastQuestion = history.lastIndexWhere((m) => m is UserMessage); + return [ + for (var i = 0; i < history.length; i++) + switch (history[i]) { + final ToolResultMessage result when i < lastQuestion => + ToolResultMessage( + callId: result.callId, + name: result.name, + content: + '[Earlier result omitted; call the tool again if it ' + 'is needed.]', + ), + final message => message, + }, + ]; + } +} diff --git a/lib/features/ai_chat/domain/chat_message.dart b/lib/features/ai_chat/domain/chat_message.dart new file mode 100644 index 000000000..fed44195e --- /dev/null +++ b/lib/features/ai_chat/domain/chat_message.dart @@ -0,0 +1,61 @@ +/// The conversation as the AI service sees it: what is sent on every turn. +/// +/// Kept apart from what the page shows. The page shows tool activity as a +/// line of status; the service needs the exact call it made and the exact +/// answer it got, in order, or the next request is rejected. +library; + +sealed class ChatMessage { + const ChatMessage(); +} + +/// The instructions that open every request. Rebuilt per turn (it carries the +/// time), never stored in the history. +final class SystemMessage extends ChatMessage { + const SystemMessage(this.text); + + final String text; +} + +final class UserMessage extends ChatMessage { + const UserMessage(this.text); + + final String text; +} + +/// What the model answered: text, tool calls, or both. +final class AssistantMessage extends ChatMessage { + const AssistantMessage({this.text = '', this.toolCalls = const []}); + + final String text; + final List toolCalls; +} + +/// The answer to one [ToolCall], matched to it by [callId]. +final class ToolResultMessage extends ChatMessage { + const ToolResultMessage({ + required this.callId, + required this.name, + required this.content, + }); + + final String callId; + final String name; + final String content; +} + +/// One tool call as the model wrote it. +final class ToolCall { + const ToolCall({ + required this.id, + required this.name, + required this.arguments, + }); + + final String id; + final String name; + + /// The arguments as the JSON text the model produced — kept verbatim, + /// because that is what has to be sent back with the call. + final String arguments; +} diff --git a/lib/features/ai_chat/domain/chat_model.dart b/lib/features/ai_chat/domain/chat_model.dart new file mode 100644 index 000000000..9f4274361 --- /dev/null +++ b/lib/features/ai_chat/domain/chat_model.dart @@ -0,0 +1,40 @@ +/// A chat-completion service, streamed. +library; + +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/tool_host.dart'; + +sealed class ModelEvent { + const ModelEvent(); +} + +/// More of the answer's text, as it arrives. +final class ModelText extends ModelEvent { + const ModelText(this.delta); + + final String delta; +} + +/// The model is reasoning before it answers. Carries no text: the reasoning +/// is not shown, only that something is happening. +final class ModelThinking extends ModelEvent { + const ModelThinking(); +} + +/// The completed message — the last event of every successful stream. +final class ModelDone extends ModelEvent { + const ModelDone(this.message); + + final AssistantMessage message; +} + +abstract class ChatModel { + /// Streams one completion over [messages], offering [tools]. + /// + /// Ends with a [ModelDone]; fails with an `AiException`. Cancelling the + /// subscription aborts the request. + Stream complete({ + required List messages, + required List tools, + }); +} diff --git a/lib/features/ai_chat/domain/system_prompt.dart b/lib/features/ai_chat/domain/system_prompt.dart new file mode 100644 index 000000000..47a3b1f4c --- /dev/null +++ b/lib/features/ai_chat/domain/system_prompt.dart @@ -0,0 +1,107 @@ +/// The instructions every AI chat request opens with. +/// +/// English whatever the UI language: models follow English instructions most +/// reliably, and the reply language is set explicitly below. +library; + +/// A township the user has in the app, named and coded for the tools. +final class PromptPlace { + const PromptPlace({ + required this.name, + required this.code, + required this.current, + }); + + final String name; + + /// The 3-digit township (postal) code the tools take. + final String code; + + /// Whether this is where the phone is now (所在地), not a saved place. + final bool current; +} + +/// Builds the system prompt. +/// +/// [taipeiNow] is a calibrated instant whose fields read as Taipei wall time +/// (`AppTime.utc8`): the model has no clock, and "today" or "the quake an +/// hour ago" means nothing without one. [places] are township names and codes +/// only — never coordinates. [serverNotes] is the tool server's own usage +/// guide (units, scales, code formats). +String buildSystemPrompt({ + required DateTime taipeiNow, + required String language, + required String localeTag, + required List places, + String? serverNotes, +}) { + final buffer = StringBuffer() + ..writeln( + 'You are the assistant inside DPIP, a disaster-information app for ' + 'Taiwan by ExpTech Studio. You help people understand earthquakes, ' + 'earthquake early warnings, weather, rain, typhoons, tsunamis and ' + 'disaster notices.', + ) + ..writeln() + ..writeln( + '- Look the data up with the tools before answering anything about ' + 'current or past conditions. Never guess or invent numbers, times or ' + 'places; if a tool fails or finds nothing, say so plainly.', + ) + ..writeln('- Say how recent the data is, from its own time or fetched_at.') + ..writeln( + '- You are not an official warning service and you can be wrong. When ' + 'someone may be in danger, tell them briefly to protect themselves first ' + 'and to follow official alerts and instructions from the Central Weather ' + 'Administration (CWA) and local authorities.', + ) + ..writeln( + '- Answers are read on a phone: keep them short. Use simple Markdown ' + '(short lists, bold); avoid wide tables and code blocks.', + ) + ..writeln( + '- Reply in $language ($localeTag) unless the user writes in another ' + 'language.', + ) + ..writeln() + ..writeln('Current time: ${_format(taipeiNow)} (Asia/Taipei, UTC+8).'); + + if (places.isNotEmpty) { + buffer + ..writeln() + ..writeln("The user's places in the app (township codes for the tools):"); + for (final place in places) { + final label = place.current ? 'Current location' : 'Saved'; + buffer.writeln('- $label: ${place.name} (${place.code})'); + } + buffer.writeln( + 'When the user says "here", "my area" or similar without naming a ' + 'place, use the current location, or the first saved place if there ' + 'is no current location.', + ); + } + + final notes = serverNotes?.trim(); + if (notes != null && notes.isNotEmpty) { + buffer + ..writeln() + ..writeln('Notes from the data server:') + ..writeln(notes); + } + return buffer.toString().trimRight(); +} + +String _format(DateTime t) { + String two(int n) => n.toString().padLeft(2, '0'); + const weekdays = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]; + return '${t.year}-${two(t.month)}-${two(t.day)} ${weekdays[t.weekday - 1]} ' + '${two(t.hour)}:${two(t.minute)}'; +} diff --git a/lib/features/ai_chat/domain/tool_host.dart b/lib/features/ai_chat/domain/tool_host.dart new file mode 100644 index 000000000..76002f05e --- /dev/null +++ b/lib/features/ai_chat/domain/tool_host.dart @@ -0,0 +1,44 @@ +/// The tools the AI service may call — the DPIP MCP server's. +library; + +/// One tool as offered to the model: its JSON Schema [parameters] are passed +/// through from the server. +final class ToolSpec { + const ToolSpec({ + required this.name, + required this.description, + required this.parameters, + }); + + final String name; + final String description; + final Map parameters; +} + +/// Everything the tool server offers, plus the usage notes it sends with them +/// (units, code formats) that belong in the model's instructions. +final class ToolCatalog { + const ToolCatalog({required this.tools, this.instructions}); + + final List tools; + final String? instructions; +} + +/// What a call returned. An error is still returned *to the model* — it can +/// try other arguments or say the data was unavailable — so it is a value, +/// not an exception. +final class ToolOutcome { + const ToolOutcome(this.content, {this.isError = false}); + + final String content; + final bool isError; +} + +abstract class ToolHost { + /// The tools on offer. Throws an `AiException` when the server cannot be + /// reached, since nothing useful can be answered without them. + Future catalog(); + + /// Runs one tool. Failures come back as an error [ToolOutcome]. + Future call(String name, Map arguments); +} diff --git a/lib/features/ai_chat/presentation/ai_chat_controller.dart b/lib/features/ai_chat/presentation/ai_chat_controller.dart new file mode 100644 index 000000000..c393d4c0f --- /dev/null +++ b/lib/features/ai_chat/presentation/ai_chat_controller.dart @@ -0,0 +1,245 @@ +/// One AI conversation, held in memory only. +/// +/// Nothing here is persisted: the conversation ends with the app process (the +/// explanation page promises as much). The page shows [entries]; the AI +/// service is sent [ChatMessage]s, which carry the exact tool calls and +/// results the page only summarises. +library; + +import 'dart:async'; +import 'dart:collection'; + +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_settings_repository.dart'; +import 'package:dpip/features/ai_chat/domain/chat_agent.dart'; +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/system_prompt.dart'; +import 'package:dpip/features/ai_chat/domain/tool_host.dart'; +import 'package:flutter/foundation.dart'; + +sealed class ChatEntry { + const ChatEntry(); +} + +final class QuestionEntry extends ChatEntry { + const QuestionEntry(this.text); + + final String text; +} + +enum ToolState { running, ok, failed } + +final class ToolActivity { + ToolActivity(this.callId, this.name); + + final String callId; + final String name; + ToolState state = ToolState.running; +} + +/// The answer to one question, filled in as it streams. +final class AnswerEntry extends ChatEntry { + AnswerEntry(this.question); + + /// What it answers — kept so a failed answer can be asked again. + final String question; + + final StringBuffer _text = StringBuffer(); + final List tools = []; + + String get text => _text.toString(); + + /// The model is reasoning and has not written anything yet. + bool thinking = false; + + bool done = false; + + /// Stopped by the user before it finished. + bool stopped = false; + + AiException? failure; +} + +class AiChatController extends ChangeNotifier { + AiChatController({ + required this._settings, + required this._models, + required this._tools, + required this._places, + required this._taipeiNow, + }); + + final AiSettingsRepository _settings; + final ChatModelFactory _models; + final ToolHost _tools; + final List Function() _places; + final DateTime Function() _taipeiNow; + + final List _entries = []; + final List _history = []; + + StreamSubscription? _turn; + AnswerEntry? _answer; + + /// Bumped by [stop] and [clear], so a turn still awaiting its key cannot + /// start after it was called off. + int _generation = 0; + + Timer? _coalesce; + + List get entries => UnmodifiableListView(_entries); + + bool get isEmpty => _entries.isEmpty; + + /// A question is being answered. + bool get busy => _answer != null; + + /// Asks [question]. [language] (the language's own name) and [localeTag] + /// set the reply language. + Future send( + String question, { + required String language, + required String localeTag, + }) async { + final text = question.trim(); + final connection = _settings.connection; + if (text.isEmpty || busy || !connection.isComplete) return; + + final generation = ++_generation; + final answer = AnswerEntry(text); + final asked = UserMessage(text); + _entries + ..add(QuestionEntry(text)) + ..add(answer); + _history.add(asked); + _answer = answer; + notifyListeners(); + + final key = await _settings.apiKey(); + if (generation != _generation) return; + + final agent = ChatAgent(model: _models(connection, key), tools: _tools); + _turn = agent + .run( + systemPrompt: (notes) => buildSystemPrompt( + taipeiNow: _taipeiNow(), + language: language, + localeTag: localeTag, + places: _places(), + serverNotes: notes, + ), + history: _history, + record: _history.add, + ) + .listen( + (event) => _apply(answer, event), + onError: (Object error) { + answer.failure = error is AiException + ? error + : const AiException(AiFailureKind.network); + _finish(answer, asked); + }, + onDone: () => _finish(answer, asked), + cancelOnError: true, + ); + } + + /// Asks a failed question again. + Future retry( + AnswerEntry failed, { + required String language, + required String localeTag, + }) async { + if (busy) return; + final index = _entries.indexOf(failed); + // The question above it goes too: send() adds both back. + if (index > 0 && _entries[index - 1] is QuestionEntry) { + _entries.removeRange(index - 1, index + 1); + } else if (index >= 0) { + _entries.removeAt(index); + } + await send(failed.question, language: language, localeTag: localeTag); + } + + /// Stops the answer in progress, keeping what it has said. + void stop() { + final answer = _answer; + if (answer == null) return; + _generation++; + answer.stopped = true; + // Cancelling records the text so far into the history synchronously. + unawaited(_turn?.cancel()); + _finish(answer, null); + } + + /// Starts over: an empty page and an empty history. + void clear() { + if (busy) stop(); + _generation++; + _entries.clear(); + _history.clear(); + notifyListeners(); + } + + void _apply(AnswerEntry answer, AgentEvent event) { + switch (event) { + case AgentText(:final delta): + answer + .._text.write(delta) + ..thinking = false; + // Text arrives a token at a time; one rebuild per few tokens is as + // smooth to read and far cheaper to lay out on a slow phone. + _coalesce ??= Timer(const Duration(milliseconds: 80), _flush); + return; + case AgentThinking(): + if (answer.thinking || answer.text.isNotEmpty) return; + answer.thinking = true; + case AgentToolStarted(:final callId, :final name): + answer + ..thinking = false + ..tools.add(ToolActivity(callId, name)); + case AgentToolFinished(:final callId, :final ok): + for (final tool in answer.tools) { + if (tool.callId == callId) { + tool.state = ok ? ToolState.ok : ToolState.failed; + } + } + } + _flush(); + } + + /// Ends [answer]'s turn. [asked] is its question, when the turn ended by + /// itself: one that left no answer in the history is taken back out, so a + /// retry or the next question does not follow an unanswered one (some + /// services reject two user messages in a row). + void _finish(AnswerEntry answer, UserMessage? asked) { + if (!identical(_answer, answer)) return; + answer + ..done = true + ..thinking = false; + _answer = null; + _turn = null; + if (_history.isNotEmpty && identical(_history.last, asked)) { + _history.removeLast(); + } + if (asked == null && _history.isNotEmpty && _history.last is UserMessage) { + _history.removeLast(); + } + _flush(); + } + + void _flush() { + _coalesce?.cancel(); + _coalesce = null; + notifyListeners(); + } + + @override + void dispose() { + _generation++; + unawaited(_turn?.cancel()); + _coalesce?.cancel(); + super.dispose(); + } +} diff --git a/lib/features/ai_chat/presentation/ai_setup.dart b/lib/features/ai_chat/presentation/ai_setup.dart new file mode 100644 index 000000000..b47c1443d --- /dev/null +++ b/lib/features/ai_chat/presentation/ai_setup.dart @@ -0,0 +1,63 @@ +/// The AI chat's setup as the pages watch it. +library; + +import 'dart:async'; + +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_settings_repository.dart'; +import 'package:flutter/foundation.dart'; + +class AiSetup extends ChangeNotifier { + AiSetup(this._repository) { + unawaited(_loadKey()); + } + + final AiSettingsRepository _repository; + + bool? _hasKey; + + bool get introSeen => _repository.introSeen; + + AiConnection get connection => _repository.connection; + + /// Whether a key is saved — false until the key storage has answered. + bool get hasKey => _hasKey ?? false; + + /// Enough to start a conversation. A key is not required: a service on + /// this phone may need none, and a missing one is reported by the service + /// itself, more precisely than a guess here could. + bool get ready => connection.isComplete; + + Future _loadKey() async { + final key = await _repository.apiKey(); + _hasKey = key != null && key.isNotEmpty; + notifyListeners(); + } + + Future markIntroSeen() async { + await _repository.markIntroSeen(); + notifyListeners(); + } + + /// See [AiSettingsRepository.save]: a null [apiKey] keeps the saved one. + Future save(AiConnection connection, {String? apiKey}) async { + await _repository.save(connection, apiKey: apiKey); + if (apiKey != null) _hasKey = apiKey.trim().isNotEmpty; + notifyListeners(); + } + + Future forget() async { + await _repository.forget(); + _hasKey = false; + notifyListeners(); + } + + /// Model ids at [baseUrl]; with no [apiKey] typed, the saved key is used. + Future> listModels(String baseUrl, String? apiKey) async { + final typed = apiKey?.trim(); + final key = typed == null || typed.isEmpty + ? await _repository.apiKey() + : typed; + return _repository.listModels(baseUrl, key); + } +} diff --git a/lib/features/ai_chat/presentation/ai_texts.dart b/lib/features/ai_chat/presentation/ai_texts.dart new file mode 100644 index 000000000..77181fd89 --- /dev/null +++ b/lib/features/ai_chat/presentation/ai_texts.dart @@ -0,0 +1,55 @@ +/// What the AI chat's failures and tool calls are called on screen. +library; + +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; + +String aiFailureText(AppLocalizations l10n, AiException failure) => + switch (failure.kind) { + AiFailureKind.unauthorized => l10n.aiErrorUnauthorized, + AiFailureKind.notFound => l10n.aiErrorNotFound, + AiFailureKind.rateLimited => l10n.aiErrorRateLimited, + AiFailureKind.rejected => l10n.aiErrorRejected, + AiFailureKind.server => l10n.aiErrorServer, + AiFailureKind.network => l10n.aiErrorNetwork, + AiFailureKind.timeout => l10n.aiErrorTimeout, + AiFailureKind.badResponse => l10n.aiErrorBadResponse, + AiFailureKind.toolsUnavailable => l10n.aiErrorToolsUnavailable, + AiFailureKind.tooManySteps => l10n.aiErrorTooManySteps, + }; + +/// Whether the fix for [failure] is in the settings page. +bool aiFailureNeedsSettings(AiException failure) => + failure.kind == AiFailureKind.unauthorized || + failure.kind == AiFailureKind.notFound || + failure.kind == AiFailureKind.badResponse; + +String? baseUrlProblemText(AppLocalizations l10n, BaseUrlProblem? problem) => + switch (problem) { + null => null, + BaseUrlProblem.empty => l10n.aiSettingsUrlEmpty, + BaseUrlProblem.invalid => l10n.aiSettingsUrlInvalid, + BaseUrlProblem.insecure => l10n.aiSettingsUrlInsecure, + }; + +/// The kind of data a DPIP tool looks up, for "looking up …" lines. +/// +/// By the tool's name rather than one string per tool: the server can add +/// tools without the app shipping new translations, and a new tool still +/// reads sensibly ("data") until someone names its kind here. +String toolSubject(AppLocalizations l10n, String tool) { + bool has(String part) => tool.contains(part); + if (has('earthquake') || tool == 'eew' || tool == 'rts') { + return l10n.aiToolEarthquake; + } + if (has('tsunami')) return l10n.aiToolTsunami; + if (has('typhoon')) return l10n.aiToolTyphoon; + if (has('event')) return l10n.aiToolNotices; + if (has('township') || has('lookup')) return l10n.aiToolPlaces; + if (has('timeline')) return l10n.aiToolTimeline; + if (has('weather') || has('meteor') || has('rain') || has('lightning')) { + return l10n.aiToolWeather; + } + return l10n.aiToolOther; +} diff --git a/lib/features/ai_chat/presentation/pages/ai_chat_page.dart b/lib/features/ai_chat/presentation/pages/ai_chat_page.dart new file mode 100644 index 000000000..a7dcb612f --- /dev/null +++ b/lib/features/ai_chat/presentation/pages/ai_chat_page.dart @@ -0,0 +1,399 @@ +/// The AI assistant, pushed over the shell from Home's region bar: the +/// explanation the first time, then the conversation. Leaving it keeps the +/// conversation, which lives in [AiChatController], not in this page. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_chat_controller.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_setup.dart'; +import 'package:dpip/features/ai_chat/presentation/widgets/ai_intro.dart'; +import 'package:dpip/features/ai_chat/presentation/widgets/ai_message_view.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +class AiChatPage extends StatelessWidget { + const AiChatPage({super.key}); + + @override + Widget build(BuildContext context) { + final setup = context.watch(); + if (!setup.introSeen) return const _IntroPage(); + return const _ChatPage(); + } +} + +void _openSettings(BuildContext context) => + context.pushNamed(AppRoutes.aiSettings); + +class _IntroPage extends StatelessWidget { + const _IntroPage(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return Scaffold( + appBar: AppBar(title: Text(l10n.aiChatTitle)), + body: ListView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + const AiIntro(), + const SizedBox(height: AppSpacing.xl), + FilledButton( + onPressed: () async { + final setup = context.read(); + await setup.markIntroSeen(); + if (!setup.ready && context.mounted) _openSettings(context); + }, + child: Text(l10n.aiIntroStart), + ), + ], + ), + ); + } +} + +class _ChatPage extends StatefulWidget { + const _ChatPage(); + + @override + State<_ChatPage> createState() => _ChatPageState(); +} + +class _ChatPageState extends State<_ChatPage> { + final TextEditingController _input = TextEditingController(); + + @override + void dispose() { + _input.dispose(); + super.dispose(); + } + + void _send(String text) { + final l10n = AppLocalizations.of(context); + final chat = context.read(); + if (text.trim().isEmpty || chat.busy) return; + _input.clear(); + chat.send( + text, + language: l10n.languageName, + localeTag: Localizations.localeOf(context).toLanguageTag(), + ); + } + + void _retry(AnswerEntry answer) { + final l10n = AppLocalizations.of(context); + context.read().retry( + answer, + language: l10n.languageName, + localeTag: Localizations.localeOf(context).toLanguageTag(), + ); + } + + Future _showIntro() => showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (sheet) => FractionallySizedBox( + heightFactor: 0.9, + child: ListView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.xl + MediaQuery.paddingOf(sheet).bottom, + ), + children: const [AiIntro()], + ), + ), + ); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final ready = context.select((s) => s.ready); + final chat = context.watch(); + return Scaffold( + appBar: AppBar( + title: Text(l10n.aiChatTitle), + actions: [ + IconButton( + tooltip: l10n.aiChatNew, + onPressed: chat.isEmpty ? null : chat.clear, + icon: const Icon(Icons.add_comment_outlined), + ), + IconButton( + tooltip: l10n.aiChatAbout, + onPressed: _showIntro, + icon: const Icon(Icons.info_outline), + ), + IconButton( + tooltip: l10n.aiSettingsTitle, + onPressed: () => _openSettings(context), + icon: const Icon(Icons.settings_outlined), + ), + ], + ), + body: !ready + ? const _SetupNeeded() + : Column( + children: [ + Expanded( + child: chat.isEmpty + ? _Suggestions(onPick: _send) + : _Conversation( + chat: chat, + onRetry: _retry, + onOpenSettings: () => _openSettings(context), + ), + ), + _Composer( + controller: _input, + busy: chat.busy, + onSend: () => _send(_input.text), + onStop: chat.stop, + ), + ], + ), + ); + } +} + +class _Conversation extends StatelessWidget { + const _Conversation({ + required this.chat, + required this.onRetry, + required this.onOpenSettings, + }); + + final AiChatController chat; + final void Function(AnswerEntry answer) onRetry; + final VoidCallback onOpenSettings; + + @override + Widget build(BuildContext context) { + final entries = chat.entries; + // Reversed, so the newest entry sits at the bottom and a streaming + // answer stays in view as it grows, with no scrolling to manage. + return ListView.builder( + reverse: true, + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, + ), + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[entries.length - 1 - index]; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + child: switch (entry) { + QuestionEntry(:final text) => QuestionBubble(text: text), + final AnswerEntry answer => AnswerView( + answer: answer, + onRetry: () => onRetry(answer), + onOpenSettings: onOpenSettings, + ), + }, + ); + }, + ); + } +} + +class _Suggestions extends StatelessWidget { + const _Suggestions({required this.onPick}); + + final void Function(String text) onPick; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final suggestions = [ + l10n.aiChatSuggestionQuakes, + l10n.aiChatSuggestionRain, + l10n.aiChatSuggestionTyphoon, + l10n.aiChatSuggestionAlerts, + ]; + return ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + const SizedBox(height: AppSpacing.xl), + Icon( + Icons.auto_awesome_outlined, + size: 40, + color: theme.colorScheme.primary, + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.aiChatEmptyTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.lg), + Wrap( + alignment: WrapAlignment.center, + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + for (final text in suggestions) + ActionChip(label: Text(text), onPressed: () => onPick(text)), + ], + ), + ], + ); + } +} + +class _SetupNeeded extends StatelessWidget { + const _SetupNeeded(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + return Center( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.xl, + AppSpacing.xl, + AppSpacing.xl + MediaQuery.paddingOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.key_outlined, + size: 40, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.aiChatSetupTitle, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.aiChatSetupBody, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.lg), + FilledButton.icon( + onPressed: () => _openSettings(context), + icon: const Icon(Icons.settings_outlined), + label: Text(l10n.aiChatSetupAction), + ), + ], + ), + ), + ); + } +} + +class _Composer extends StatelessWidget { + const _Composer({ + required this.controller, + required this.busy, + required this.onSend, + required this.onStop, + }); + + final TextEditingController controller; + final bool busy; + final VoidCallback onSend; + final VoidCallback onStop; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Material( + color: colors.surface, + child: Padding( + // Clears the system gesture area; with the keyboard up the Scaffold + // has already resized for it and this inset is zero. + padding: EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.xs + MediaQuery.paddingOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: controller, + minLines: 1, + maxLines: 5, + keyboardType: TextInputType.multiline, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: l10n.aiChatInputHint, + filled: true, + fillColor: colors.surfaceContainerHigh, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + border: const OutlineInputBorder( + borderRadius: AppRadius.large, + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + busy + ? IconButton.filledTonal( + tooltip: l10n.aiChatStop, + onPressed: onStop, + icon: const Icon(Icons.stop), + ) + : ListenableBuilder( + listenable: controller, + builder: (context, _) => IconButton.filled( + tooltip: l10n.aiChatSend, + onPressed: controller.text.trim().isEmpty + ? null + : onSend, + icon: const Icon(Icons.arrow_upward), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.aiChatDisclaimer, + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/ai_chat/presentation/pages/ai_settings_page.dart b/lib/features/ai_chat/presentation/pages/ai_settings_page.dart new file mode 100644 index 000000000..173a5b7a3 --- /dev/null +++ b/lib/features/ai_chat/presentation/pages/ai_settings_page.dart @@ -0,0 +1,366 @@ +/// Setting up the user's own AI service: base URL, key, model. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_setup.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_texts.dart'; +import 'package:dpip/features/ai_chat/presentation/widgets/mcp_url_card.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/widgets/section_header.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +class AiSettingsPage extends StatefulWidget { + const AiSettingsPage({super.key}); + + @override + State createState() => _AiSettingsPageState(); +} + +class _AiSettingsPageState extends State { + final TextEditingController _url = TextEditingController(); + final TextEditingController _key = TextEditingController(); + final TextEditingController _model = TextEditingController(); + + bool _showKey = false; + bool _saving = false; + bool _listing = false; + String? _urlError; + String? _modelError; + String? _listError; + + @override + void initState() { + super.initState(); + final saved = context.read().connection; + _url.text = saved.baseUrl; + _model.text = saved.model; + } + + @override + void dispose() { + _url.dispose(); + _key.dispose(); + _model.dispose(); + super.dispose(); + } + + String? _validUrl(AppLocalizations l10n) { + final (:url, :problem) = normalizeBaseUrl(_url.text); + setState(() => _urlError = baseUrlProblemText(l10n, problem)); + return url; + } + + void _toast(String text) => ScaffoldMessenger.maybeOf(context) + ?..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(text))); + + Future _pickModel() async { + final l10n = AppLocalizations.of(context); + final url = _validUrl(l10n); + if (url == null) return; + setState(() { + _listing = true; + _listError = null; + }); + List models = const []; + try { + models = await context.read().listModels(url, _key.text); + } on AiException catch (error) { + if (mounted) setState(() => _listError = aiFailureText(l10n, error)); + } finally { + if (mounted) setState(() => _listing = false); + } + if (!mounted || _listError != null) return; + if (models.isEmpty) { + setState(() => _listError = l10n.aiSettingsNoModels); + return; + } + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (_) => _ModelPicker(models: models), + ); + if (picked != null && mounted) { + setState(() { + _model.text = picked; + _modelError = null; + }); + } + } + + Future _save() async { + final l10n = AppLocalizations.of(context); + final url = _validUrl(l10n); + final model = _model.text.trim(); + setState( + () => _modelError = model.isEmpty ? l10n.aiSettingsModelEmpty : null, + ); + if (url == null || model.isEmpty) return; + final typedKey = _key.text.trim(); + setState(() => _saving = true); + try { + await context.read().save( + AiConnection(baseUrl: url, model: model), + // Left blank: keep the key already saved. + apiKey: typedKey.isEmpty ? null : typedKey, + ); + } catch (_) { + // The platform's key storage refused; its message is not the user's + // to read, and the log already has the code. + if (mounted) { + setState(() => _saving = false); + _toast(l10n.aiSettingsSaveFailed); + } + return; + } + if (!mounted) return; + _key.clear(); + setState(() => _saving = false); + _toast(l10n.aiSettingsSaved); + if (context.canPop()) context.pop(); + } + + Future _removeKey() async { + final l10n = AppLocalizations.of(context); + final setup = context.read(); + await setup.save(setup.connection, apiKey: ''); + if (mounted) _toast(l10n.aiSettingsKeyRemoved); + } + + Future _forget() async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (dialog) => AlertDialog( + title: Text(l10n.aiSettingsForgetTitle), + content: Text(l10n.aiSettingsForgetBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialog, false), + child: Text(l10n.commonCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialog, true), + child: Text(l10n.aiSettingsForgetConfirm), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await context.read().forget(); + if (!mounted) return; + setState(() { + _url.clear(); + _key.clear(); + _model.clear(); + _urlError = _modelError = _listError = null; + }); + _toast(l10n.aiSettingsForgotten); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final setup = context.watch(); + final muted = theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ); + return Scaffold( + appBar: AppBar(title: Text(l10n.aiSettingsTitle)), + body: ListView( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl + MediaQuery.paddingOf(context).bottom, + ), + children: [ + SectionHeader(l10n.aiSettingsService), + Text(l10n.aiSettingsServiceBody, style: muted), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + for (final preset in aiPresets) + ActionChip( + label: Text(preset.name), + onPressed: () => setState(() { + _url.text = preset.baseUrl; + _urlError = null; + _listError = null; + }), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + TextField( + controller: _url, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.aiSettingsBaseUrl, + hintText: aiPresets.first.baseUrl, + helperText: l10n.aiSettingsBaseUrlHelper, + helperMaxLines: 3, + errorText: _urlError, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: AppSpacing.lg), + TextField( + controller: _key, + obscureText: !_showKey, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.aiSettingsApiKey, + helperText: setup.hasKey + ? l10n.aiSettingsApiKeySaved + : l10n.aiSettingsApiKeyHelper, + helperMaxLines: 3, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + tooltip: _showKey + ? l10n.aiSettingsHideKey + : l10n.aiSettingsShowKey, + onPressed: () => setState(() => _showKey = !_showKey), + icon: Icon( + _showKey + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + ), + if (setup.hasKey) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: _removeKey, + icon: const Icon(Icons.key_off_outlined), + label: Text(l10n.aiSettingsRemoveKey), + ), + ), + const SizedBox(height: AppSpacing.lg), + TextField( + controller: _model, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l10n.aiSettingsModel, + helperText: l10n.aiSettingsModelHelper, + helperMaxLines: 3, + errorText: _modelError ?? _listError, + errorMaxLines: 4, + border: const OutlineInputBorder(), + suffixIcon: _listing + ? const Padding( + padding: EdgeInsets.all(AppSpacing.md), + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + tooltip: l10n.aiSettingsFetchModels, + onPressed: _pickModel, + icon: const Icon(Icons.list_alt_outlined), + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + FilledButton( + onPressed: _saving ? null : _save, + child: Text(l10n.aiSettingsSave), + ), + const SizedBox(height: AppSpacing.xxl), + SectionHeader(l10n.aiSettingsPrivacy), + Text(l10n.aiSettingsPrivacyBody, style: muted), + const SizedBox(height: AppSpacing.xl), + const McpUrlCard(), + if (setup.connection.baseUrl.isNotEmpty || setup.hasKey) ...[ + const SizedBox(height: AppSpacing.xl), + OutlinedButton.icon( + onPressed: _forget, + style: OutlinedButton.styleFrom(foregroundColor: colors.error), + icon: const Icon(Icons.delete_outline), + label: Text(l10n.aiSettingsForget), + ), + ], + ], + ), + ); + } +} + +class _ModelPicker extends StatefulWidget { + const _ModelPicker({required this.models}); + + final List models; + + @override + State<_ModelPicker> createState() => _ModelPickerState(); +} + +class _ModelPickerState extends State<_ModelPicker> { + String _filter = ''; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final needle = _filter.toLowerCase(); + final shown = [ + for (final id in widget.models) + if (needle.isEmpty || id.toLowerCase().contains(needle)) id, + ]; + return FractionallySizedBox( + heightFactor: 0.85, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.sm, + ), + child: TextField( + autofocus: widget.models.length > 12, + autocorrect: false, + onChanged: (text) => setState(() => _filter = text.trim()), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: l10n.aiSettingsFilterModels, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ), + Expanded( + child: ListView.builder( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom, + ), + itemCount: shown.length, + itemBuilder: (context, index) => ListTile( + leading: const Icon(Icons.memory_outlined), + title: Text(shown[index]), + onTap: () => Navigator.pop(context, shown[index]), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/ai_chat/presentation/widgets/ai_intro.dart b/lib/features/ai_chat/presentation/widgets/ai_intro.dart new file mode 100644 index 000000000..74e033152 --- /dev/null +++ b/lib/features/ai_chat/presentation/widgets/ai_intro.dart @@ -0,0 +1,102 @@ +/// What the AI chat is, where its data goes, and what it is not. +/// +/// Shown in full the first time the tab is opened, and again from the chat's +/// info button. Every claim here is one the code keeps: the key in platform +/// key storage only, the conversation in memory only, township names and +/// codes (never coordinates) in the instructions. +library; + +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/features/ai_chat/presentation/widgets/mcp_url_card.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; + +class AiIntro extends StatelessWidget { + const AiIntro({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.auto_awesome_outlined, + size: 40, + color: theme.colorScheme.primary, + ), + const SizedBox(height: AppSpacing.md), + Text(l10n.aiIntroTitle, style: theme.textTheme.headlineSmall), + const SizedBox(height: AppSpacing.sm), + Text(l10n.aiIntroBody, style: theme.textTheme.bodyLarge), + const SizedBox(height: AppSpacing.xl), + _Point( + icon: Icons.key_outlined, + title: l10n.aiIntroOwnKeyTitle, + body: l10n.aiIntroOwnKeyBody, + ), + _Point( + icon: Icons.phone_android_outlined, + title: l10n.aiIntroLocalTitle, + body: l10n.aiIntroLocalBody, + ), + _Point( + icon: Icons.swap_horiz, + title: l10n.aiIntroDataTitle, + body: l10n.aiIntroDataBody, + ), + _Point( + icon: Icons.warning_amber_rounded, + title: l10n.aiIntroCautionTitle, + body: l10n.aiIntroCautionBody, + ), + _Point( + icon: Icons.hub_outlined, + title: l10n.aiIntroMcpTitle, + body: l10n.aiIntroMcpBody, + ), + const McpUrlCard(), + ], + ); + } +} + +class _Point extends StatelessWidget { + const _Point({required this.icon, required this.title, required this.body}); + + final IconData icon; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: colors.primary), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall), + const SizedBox(height: AppSpacing.xs), + Text( + body, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/ai_chat/presentation/widgets/ai_message_view.dart b/lib/features/ai_chat/presentation/widgets/ai_message_view.dart new file mode 100644 index 000000000..eb275713a --- /dev/null +++ b/lib/features/ai_chat/presentation/widgets/ai_message_view.dart @@ -0,0 +1,335 @@ +/// The conversation's entries: questions as bubbles, answers full width. +/// +/// An answer is not in a bubble: it is Markdown that may hold a list or a +/// short table, and a bubble's width would wrap it into a column of words. +library; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_chat_controller.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_texts.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class QuestionBubble extends StatelessWidget { + const QuestionBubble({super.key, required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + return Align( + alignment: Alignment.centerRight, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.8, + ), + child: Material( + color: colors.primaryContainer, + borderRadius: AppRadius.medium, + child: InkWell( + borderRadius: AppRadius.medium, + onLongPress: () => copyWithToast(context, text), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onPrimaryContainer, + ), + ), + ), + ), + ), + ), + ); + } +} + +class AnswerView extends StatelessWidget { + const AnswerView({ + super.key, + required this.answer, + required this.onRetry, + required this.onOpenSettings, + }); + + final AnswerEntry answer; + final VoidCallback onRetry; + final VoidCallback onOpenSettings; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final text = answer.text; + final failure = answer.failure; + final waiting = !answer.done && text.isEmpty; + final hasRunningTool = answer.tools.any( + (t) => t.state == ToolState.running, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (subject, state) in _toolLines(l10n)) + _ToolLine(subject: subject, state: state), + if (waiting && !hasRunningTool) + _Pending( + label: answer.thinking ? l10n.aiChatThinking : l10n.aiChatWaiting, + ), + if (text.isNotEmpty) + GestureDetector( + onLongPress: answer.done + ? () => copyWithToast(context, text) + : null, + child: MarkdownBody( + data: text, + selectable: answer.done, + softLineBreak: true, + onTapLink: (_, href, _) => _open(href), + styleSheet: _sheet(theme), + ), + ), + if (answer.stopped) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Text( + l10n.aiChatStopped, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + if (failure != null) + _FailureBox( + message: aiFailureText(l10n, failure), + detail: failure.detail, + status: failure.status, + onRetry: onRetry, + onOpenSettings: aiFailureNeedsSettings(failure) + ? onOpenSettings + : null, + ), + ], + ); + } + + /// One line per kind of data looked up, in first-seen order: four calls + /// for weather read as one "looked up weather", not four. + List<(String, ToolState)> _toolLines(AppLocalizations l10n) { + final lines = {}; + for (final tool in answer.tools) { + final subject = toolSubject(l10n, tool.name); + final seen = lines[subject]; + lines[subject] = switch ((seen, tool.state)) { + (ToolState.running, _) || (_, ToolState.running) => ToolState.running, + (ToolState.ok, _) || (_, ToolState.ok) => ToolState.ok, + _ => ToolState.failed, + }; + } + return [for (final MapEntry(:key, :value) in lines.entries) (key, value)]; + } + + static MarkdownStyleSheet _sheet(ThemeData theme) { + final body = theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + height: 1.5, + ); + return MarkdownStyleSheet.fromTheme(theme).copyWith( + p: body, + listBullet: body, + strong: body?.copyWith(fontWeight: FontWeight.w700), + code: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + codeblockDecoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: AppRadius.small, + ), + listIndent: AppSpacing.lg, + ); + } + + static Future _open(String? href) async { + final uri = href == null ? null : Uri.tryParse(href); + if (uri == null || !(uri.scheme == 'https' || uri.scheme == 'http')) { + return; + } + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (error, stackTrace) { + Log.handle(error, stackTrace, 'ai chat: opening a link'); + } + } +} + +class _ToolLine extends StatelessWidget { + const _ToolLine({required this.subject, required this.state}); + + final String subject; + final ToolState state; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final style = theme.textTheme.labelMedium?.copyWith( + color: state == ToolState.failed ? colors.error : colors.onSurfaceVariant, + ); + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: Row( + children: [ + SizedBox.square( + dimension: 16, + child: switch (state) { + ToolState.running => const CircularProgressIndicator( + strokeWidth: 2, + ), + ToolState.ok => Icon( + Icons.check_circle_outline, + size: 16, + color: colors.primary, + ), + ToolState.failed => Icon( + Icons.error_outline, + size: 16, + color: colors.error, + ), + }, + ), + const SizedBox(width: AppSpacing.sm), + Flexible( + child: Text(switch (state) { + ToolState.running => l10n.aiToolRunning(subject), + ToolState.ok => l10n.aiToolDone(subject), + ToolState.failed => l10n.aiToolFailed(subject), + }, style: style), + ), + ], + ), + ); + } +} + +class _Pending extends StatelessWidget { + const _Pending({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Row( + children: [ + const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: AppSpacing.sm), + Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _FailureBox extends StatelessWidget { + const _FailureBox({ + required this.message, + required this.detail, + required this.status, + required this.onRetry, + required this.onOpenSettings, + }); + + final String message; + final String? detail; + final int? status; + final VoidCallback onRetry; + final VoidCallback? onOpenSettings; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + // The service's own words, untranslated, with the status beside them. + final said = [if (status != null) 'HTTP $status', ?detail].join(' · '); + return Container( + margin: const EdgeInsets.only(top: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.errorContainer, + borderRadius: AppRadius.medium, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onErrorContainer, + ), + ), + if (said.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xs), + SelectableText( + l10n.aiErrorServiceSaid(said), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onErrorContainer, + ), + ), + ], + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + children: [ + FilledButton.tonalIcon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(l10n.commonRetry), + ), + if (onOpenSettings != null) + OutlinedButton.icon( + onPressed: onOpenSettings, + icon: const Icon(Icons.settings_outlined), + label: Text(l10n.aiChatOpenSettings), + ), + ], + ), + ], + ), + ); + } +} + +Future copyWithToast(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context) + ?..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).aiChatCopied)), + ); +} diff --git a/lib/features/ai_chat/presentation/widgets/mcp_url_card.dart b/lib/features/ai_chat/presentation/widgets/mcp_url_card.dart new file mode 100644 index 000000000..39ad9d2de --- /dev/null +++ b/lib/features/ai_chat/presentation/widgets/mcp_url_card.dart @@ -0,0 +1,80 @@ +/// The DPIP MCP server's address, for people building their own tools on it. +library; + +import 'dart:convert'; + +import 'package:dpip/app/theme/app_radius.dart'; +import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/network/api_paths.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class McpUrlCard extends StatelessWidget { + const McpUrlCard({super.key}); + + /// The shape most MCP clients take for a remote (Streamable HTTP) server. + static final String configExample = const JsonEncoder.withIndent(' ') + .convert({ + 'mcpServers': { + 'dpip': {'type': 'http', 'url': ApiPaths.mcpPublicUrl}, + }, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final mono = theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'); + return Material( + color: colors.surfaceContainer, + borderRadius: AppRadius.medium, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.aiMcpTitle, style: theme.textTheme.titleSmall), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.aiMcpBody, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + SelectableText(ApiPaths.mcpPublicUrl, style: mono), + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + OutlinedButton.icon( + onPressed: () => _copy(context, ApiPaths.mcpPublicUrl), + icon: const Icon(Icons.content_copy_outlined), + label: Text(l10n.aiMcpCopyUrl), + ), + OutlinedButton.icon( + onPressed: () => _copy(context, configExample), + icon: const Icon(Icons.data_object_outlined), + label: Text(l10n.aiMcpCopyConfig), + ), + ], + ), + ], + ), + ), + ); + } + + static Future _copy(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context) + ?..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context).aiMcpCopied)), + ); + } +} diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index ae542e021..5fa86c57e 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -397,6 +397,16 @@ class _HomePageState extends State { blend: dials.blend, dismiss: dials.dismiss, skyIsLight: skyIsLightFrom(sky, weatherMode), + // The AI assistant's one way in: a page + // pushed over the shell, so the bottom bar + // stays at five destinations. + trailing: IconButton( + tooltip: AppLocalizations.of(context) + .aiChatTitle, + onPressed: () => + context.pushNamed(AppRoutes.ai), + icon: const Icon(Icons.auto_awesome_outlined), + ), ), SizedBox( height: stackHeight, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f9a66e84b..e61791075 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4393,5 +4393,187 @@ "onboardingNotifySkip": "Skip for now", "@onboardingNotifySkip": { "description": "Onboarding: finish without applying a notification preset after a failed attempt" - } + }, + "aiChatTitle": "AI Assistant", + "@aiChatTitle": {"description": "App bar title of the AI chat"}, + "aiChatNew": "New conversation", + "@aiChatNew": {"description": "Tooltip: start a new, empty conversation"}, + "aiChatAbout": "About the AI assistant", + "@aiChatAbout": {"description": "Tooltip: show the AI chat explanation again"}, + "aiChatInputHint": "Ask about earthquakes, weather or typhoons…", + "@aiChatInputHint": {"description": "Hint in the empty question field"}, + "aiChatSend": "Send", + "@aiChatSend": {"description": "Tooltip of the send button"}, + "aiChatStop": "Stop", + "@aiChatStop": {"description": "Tooltip of the button that stops an answer in progress"}, + "aiChatDisclaimer": "AI can be wrong. In an emergency, follow official alerts.", + "@aiChatDisclaimer": {"description": "Always-visible line under the question field"}, + "aiChatEmptyTitle": "What would you like to know?", + "@aiChatEmptyTitle": {"description": "Heading of an empty conversation, above suggested questions"}, + "aiChatSuggestionQuakes": "Any earthquakes recently?", + "@aiChatSuggestionQuakes": {"description": "Suggested question; tapping it asks it"}, + "aiChatSuggestionRain": "Will it rain here today?", + "@aiChatSuggestionRain": {"description": "Suggested question about the user's own area"}, + "aiChatSuggestionTyphoon": "Is there a typhoon right now?", + "@aiChatSuggestionTyphoon": {"description": "Suggested question"}, + "aiChatSuggestionAlerts": "What disaster alerts are in effect?", + "@aiChatSuggestionAlerts": {"description": "Suggested question"}, + "aiChatThinking": "Thinking…", + "@aiChatThinking": {"description": "Shown while a reasoning model thinks before answering"}, + "aiChatWaiting": "Waiting for a reply…", + "@aiChatWaiting": {"description": "Shown until the AI service sends its first word"}, + "aiChatStopped": "Stopped", + "@aiChatStopped": {"description": "Under an answer the user stopped"}, + "aiChatOpenSettings": "Open settings", + "@aiChatOpenSettings": {"description": "Button under an error that is fixed in the AI settings"}, + "aiChatCopied": "Copied", + "@aiChatCopied": {"description": "Toast after a message is copied"}, + "aiChatSetupTitle": "No AI service set up yet", + "@aiChatSetupTitle": {"description": "Title shown when no AI service is set up yet"}, + "aiChatSetupBody": "Enter your own AI service's URL, key and model to start.", + "@aiChatSetupBody": {"description": "Body shown when no AI service is set up yet"}, + "aiChatSetupAction": "Set up", + "@aiChatSetupAction": {"description": "Button that opens the AI settings"}, + "aiIntroTitle": "AI Assistant", + "@aiIntroTitle": {"description": "Heading of the AI chat explanation page"}, + "aiIntroBody": "Ask about recent earthquakes, tomorrow's weather or a typhoon's track. The AI looks the data up in DPIP first, then answers in your language.", + "@aiIntroBody": {"description": "Opening paragraph of the AI chat explanation"}, + "aiIntroOwnKeyTitle": "Use your own AI service", + "@aiIntroOwnKeyTitle": {"description": "Explanation point title"}, + "aiIntroOwnKeyBody": "Enter the URL and key of any OpenAI-compatible AI service that supports tool calls, such as OpenAI, Google Gemini, Anthropic or OpenRouter. Usage and charges are counted by your AI service.", + "@aiIntroOwnKeyBody": {"description": "Explanation point body; service names are proper nouns"}, + "aiIntroLocalTitle": "Your key stays on this phone; conversations are not saved", + "@aiIntroLocalTitle": {"description": "Explanation point title"}, + "aiIntroLocalBody": "The API key is kept in the system's key storage (iOS Keychain, Android Keystore). It is never uploaded to ExpTech and is not carried to another phone by a backup. Conversations are not saved: they disappear when the app is closed.", + "@aiIntroLocalBody": {"description": "Explanation point body: where the key is stored, and that conversations are not saved"}, + "aiIntroDataTitle": "Where your data goes", + "@aiIntroDataTitle": {"description": "Explanation point title"}, + "aiIntroDataBody": "Your questions go straight from this phone to the AI service you set up, not through ExpTech. When the AI needs data, the app asks DPIP's public data server, sending only the query (such as an area code or a date) — never your key or the conversation. So the AI knows where \"here\" is, it is told your current township and saved townships in the app (names and codes only).", + "@aiIntroDataBody": {"description": "Explanation point body: what is sent where. Townships are sent as names and codes only, never coordinates"}, + "aiIntroCautionTitle": "The AI can be wrong", + "@aiIntroCautionTitle": {"description": "Explanation point title"}, + "aiIntroCautionBody": "Its answers are for reference only. If you may be in danger, protect yourself first and follow official alerts and instructions from the Central Weather Administration and the government.", + "@aiIntroCautionBody": {"description": "Explanation point body: the AI is not an official warning source"}, + "aiIntroMcpTitle": "Build your own", + "@aiIntroMcpTitle": {"description": "Explanation point title: using the DPIP MCP server in other tools"}, + "aiIntroMcpBody": "DPIP's data is also open as an MCP server — read-only, no key needed. Connect it to Claude, ChatGPT or any other MCP-capable tool and build something of your own.", + "@aiIntroMcpBody": {"description": "Explanation point body; Claude and ChatGPT are product names"}, + "aiIntroStart": "Get started", + "@aiIntroStart": {"description": "Button that leaves the explanation and opens the AI settings"}, + "aiMcpTitle": "DPIP MCP server", + "@aiMcpTitle": {"description": "Title of the card with the MCP server URL"}, + "aiMcpBody": "Any MCP client that supports Streamable HTTP can use this address directly.", + "@aiMcpBody": {"description": "Card body; Streamable HTTP is the MCP transport name, not translated"}, + "aiMcpCopyUrl": "Copy URL", + "@aiMcpCopyUrl": {"description": "Button: copy the MCP server URL"}, + "aiMcpCopyConfig": "Copy config example", + "@aiMcpCopyConfig": {"description": "Button: copy an example MCP client configuration"}, + "aiMcpCopied": "Copied", + "@aiMcpCopied": {"description": "Toast after copying the MCP URL or config"}, + "aiSettingsTitle": "AI settings", + "@aiSettingsTitle": {"description": "Title of the AI settings page"}, + "aiSettingsService": "AI service", + "@aiSettingsService": {"description": "Section header in AI settings"}, + "aiSettingsServiceBody": "Any OpenAI-compatible service (/chat/completions) with tool calls works. Tap a service below to fill in its URL.", + "@aiSettingsServiceBody": {"description": "Text under the AI service header; /chat/completions is an API path"}, + "aiSettingsBaseUrl": "API URL", + "@aiSettingsBaseUrl": {"description": "Label of the API URL field"}, + "aiSettingsBaseUrlHelper": "The API's base URL, usually ending in /v1.", + "@aiSettingsBaseUrlHelper": {"description": "Helper under the API URL field"}, + "aiSettingsUrlEmpty": "Enter a URL", + "@aiSettingsUrlEmpty": {"description": "Error: the URL field is empty"}, + "aiSettingsUrlInvalid": "This is not a valid URL", + "@aiSettingsUrlInvalid": {"description": "Error: the URL is not a URL"}, + "aiSettingsUrlInsecure": "Use https://. Plain http:// is only allowed to this phone itself (localhost).", + "@aiSettingsUrlInsecure": {"description": "Error: plain http to another host; https:// and http:// are literal"}, + "aiSettingsApiKey": "API key", + "@aiSettingsApiKey": {"description": "Label of the API key field"}, + "aiSettingsApiKeyHelper": "Stored only in this phone's key storage. Leave empty for a service that needs no key.", + "@aiSettingsApiKeyHelper": {"description": "Helper under the API key field when no key is saved"}, + "aiSettingsApiKeySaved": "A key is saved. Leave empty to keep it.", + "@aiSettingsApiKeySaved": {"description": "Helper under the API key field when a key is saved"}, + "aiSettingsShowKey": "Show key", + "@aiSettingsShowKey": {"description": "Tooltip: reveal the typed key"}, + "aiSettingsHideKey": "Hide key", + "@aiSettingsHideKey": {"description": "Tooltip: hide the typed key"}, + "aiSettingsRemoveKey": "Delete the saved key", + "@aiSettingsRemoveKey": {"description": "Button: delete the saved key"}, + "aiSettingsKeyRemoved": "Key deleted", + "@aiSettingsKeyRemoved": {"description": "Toast after the saved key is deleted"}, + "aiSettingsModel": "Model", + "@aiSettingsModel": {"description": "Label of the model field"}, + "aiSettingsModelHelper": "Must support tool calls (function calling). The button on the right lists the service's models.", + "@aiSettingsModelHelper": {"description": "Helper under the model field"}, + "aiSettingsModelEmpty": "Enter or pick a model", + "@aiSettingsModelEmpty": {"description": "Error: no model given"}, + "aiSettingsFetchModels": "List models", + "@aiSettingsFetchModels": {"description": "Tooltip: fetch the service's model list"}, + "aiSettingsFilterModels": "Filter models", + "@aiSettingsFilterModels": {"description": "Hint of the search field in the model list"}, + "aiSettingsNoModels": "This service lists no models. Type the model name instead.", + "@aiSettingsNoModels": {"description": "Error: the service returned an empty model list"}, + "aiSettingsSave": "Save", + "@aiSettingsSave": {"description": "Button: save the AI settings"}, + "aiSettingsSaved": "Saved", + "@aiSettingsSaved": {"description": "Toast after saving"}, + "aiSettingsSaveFailed": "The key could not be saved. Please try again.", + "@aiSettingsSaveFailed": {"description": "Toast: the platform's key storage refused the key"}, + "aiSettingsPrivacy": "Privacy", + "@aiSettingsPrivacy": {"description": "Section header in AI settings"}, + "aiSettingsPrivacyBody": "The key stays in this phone's key storage and is never uploaded to ExpTech. Conversations travel only between this phone and your AI service, and are not saved. When the AI looks data up, only the query goes to DPIP's data server.", + "@aiSettingsPrivacyBody": {"description": "Short privacy summary in AI settings"}, + "aiSettingsForget": "Clear all AI settings", + "@aiSettingsForget": {"description": "Button: delete URL, model and key"}, + "aiSettingsForgetTitle": "Clear AI settings?", + "@aiSettingsForgetTitle": {"description": "Confirmation dialog title"}, + "aiSettingsForgetBody": "The URL, model and key will be deleted from this phone.", + "@aiSettingsForgetBody": {"description": "Confirmation dialog body"}, + "aiSettingsForgetConfirm": "Clear", + "@aiSettingsForgetConfirm": {"description": "Confirmation dialog: the destructive action"}, + "aiSettingsForgotten": "AI settings cleared", + "@aiSettingsForgotten": {"description": "Toast after clearing the AI settings"}, + "aiToolEarthquake": "earthquake data", + "@aiToolEarthquake": {"description": "Kind of data a tool looks up; inserted into the \"looking up\" lines"}, + "aiToolWeather": "weather data", + "@aiToolWeather": {"description": "Kind of data a tool looks up"}, + "aiToolTyphoon": "typhoon data", + "@aiToolTyphoon": {"description": "Kind of data a tool looks up"}, + "aiToolTsunami": "tsunami data", + "@aiToolTsunami": {"description": "Kind of data a tool looks up"}, + "aiToolNotices": "disaster notices", + "@aiToolNotices": {"description": "Kind of data a tool looks up"}, + "aiToolPlaces": "places", + "@aiToolPlaces": {"description": "Kind of data a tool looks up"}, + "aiToolTimeline": "data times", + "@aiToolTimeline": {"description": "Kind of data a tool looks up: which times have data"}, + "aiToolOther": "data", + "@aiToolOther": {"description": "Kind of data a tool looks up, when the tool is not one the app knows"}, + "aiToolRunning": "Looking up {subject}…", + "@aiToolRunning": {"description": "A tool call in progress; {subject} is one of the aiTool* kinds", "placeholders": {"subject": {"type": "String", "example": "weather data"}}}, + "aiToolDone": "Looked up {subject}", + "@aiToolDone": {"description": "A finished tool call", "placeholders": {"subject": {"type": "String", "example": "weather data"}}}, + "aiToolFailed": "Couldn't look up {subject}", + "@aiToolFailed": {"description": "A tool call that failed", "placeholders": {"subject": {"type": "String", "example": "weather data"}}}, + "aiErrorUnauthorized": "The API key is invalid or expired, or has no access to this model.", + "@aiErrorUnauthorized": {"description": "AI service answered 401/403"}, + "aiErrorNotFound": "The API or the model was not found. Check the URL and the model name.", + "@aiErrorNotFound": {"description": "AI service answered 404"}, + "aiErrorRateLimited": "Your AI service's rate limit or quota was reached. Try again later or check your plan.", + "@aiErrorRateLimited": {"description": "AI service answered 429"}, + "aiErrorRejected": "The AI service refused the request. The model may not support tool calls.", + "@aiErrorRejected": {"description": "AI service refused the request with another 4xx"}, + "aiErrorServer": "The AI service had a problem. Try again later.", + "@aiErrorServer": {"description": "AI service answered 5xx"}, + "aiErrorNetwork": "Couldn't reach the AI service. Check your connection.", + "@aiErrorNetwork": {"description": "No connection to the AI service"}, + "aiErrorTimeout": "The AI service took too long to respond.", + "@aiErrorTimeout": {"description": "AI service went quiet too long"}, + "aiErrorBadResponse": "The AI service's reply could not be read. This URL may not be an OpenAI-compatible API.", + "@aiErrorBadResponse": {"description": "AI service answered with something that is not a chat completion"}, + "aiErrorToolsUnavailable": "Couldn't reach the DPIP data server. Try again later.", + "@aiErrorToolsUnavailable": {"description": "The DPIP MCP server could not be reached"}, + "aiErrorTooManySteps": "The AI looked up data too many times without an answer. Try a more specific question.", + "@aiErrorTooManySteps": {"description": "The model kept calling tools past the per-question limit"}, + "aiErrorServiceSaid": "Service said: {detail}", + "@aiErrorServiceSaid": {"description": "The AI service's own error text, untranslated; {detail} is e.g. \"HTTP 401 · Invalid API key\"", "placeholders": {"detail": {"type": "String", "example": "HTTP 401 · Invalid API key"}}} } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index d4122bbd3..26c59c72d 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "Inilalapat…", "onboardingNotifyTitle": "Iyong mga alerto", "onboardingNotifyBody": "Pumili ng panimulang setting. Maaari mong ayusin ang bawat channel sa Mga setting ng notipikasyon anumang oras, at bawat opsyon ay nananatiling may emergency na alerto sa lindol.", - "onboardingNotifySkip": "Laktawan muna" + "onboardingNotifySkip": "Laktawan muna", + "aiChatTitle": "AI Assistant", + "aiChatNew": "Bagong usapan", + "aiChatAbout": "Tungkol dito", + "aiChatInputHint": "Magtanong tungkol sa lindol, panahon o bagyo…", + "aiChatSend": "Ipadala", + "aiChatStop": "Itigil", + "aiChatDisclaimer": "Maaaring magkamali ang AI. Sa emergency, sundin ang opisyal na babala.", + "aiChatEmptyTitle": "Ano ang gusto mong malaman?", + "aiChatSuggestionQuakes": "May lindol ba kamakailan?", + "aiChatSuggestionRain": "Uulan ba rito ngayong araw?", + "aiChatSuggestionTyphoon": "May bagyo ba ngayon?", + "aiChatSuggestionAlerts": "Anong mga babala sa sakuna ang umiiral?", + "aiChatThinking": "Nag-iisip…", + "aiChatWaiting": "Hinihintay ang sagot…", + "aiChatStopped": "Itinigil", + "aiChatOpenSettings": "Buksan ang settings", + "aiChatCopied": "Nakopya", + "aiChatSetupTitle": "Hindi pa naka-set up ang AI service", + "aiChatSetupBody": "Ilagay ang URL, key at model ng sarili mong AI service para magsimula.", + "aiChatSetupAction": "I-set up", + "aiIntroTitle": "AI Assistant", + "aiIntroBody": "Magtanong tungkol sa mga kamakailang lindol, panahon bukas o daan ng bagyo. Hahanapin muna ng AI ang datos ng DPIP nang real-time, saka sasagot sa iyong wika.", + "aiIntroOwnKeyTitle": "Gamitin ang sarili mong AI service", + "aiIntroOwnKeyBody": "Ilagay ang URL at key ng anumang AI service na compatible sa OpenAI at sumusuporta sa tool calls, gaya ng OpenAI, Google Gemini, Anthropic o OpenRouter. Ang paggamit at singil ay kinukuwenta ng iyong AI service.", + "aiIntroLocalTitle": "Sa teleponong ito lang ang key; hindi sine-save ang usapan", + "aiIntroLocalBody": "Nakatago ang API key sa key storage ng system (iOS Keychain, Android Keystore). Hindi ito ina-upload sa ExpTech at hindi nadadala sa ibang telepono sa pamamagitan ng backup. Hindi sine-save ang mga usapan: nawawala ang mga ito kapag isinara ang app.", + "aiIntroDataTitle": "Saan napupunta ang datos", + "aiIntroDataBody": "Direktang ipinapadala ang mga tanong mo mula sa teleponong ito papunta sa AI service na itinakda mo, hindi dumadaan sa ExpTech. Kapag kailangan ng AI ng datos, nagtatanong ang app sa pampublikong data server ng DPIP at ipinapadala lang ang query (gaya ng area code o petsa), hindi ang key o ang usapan. Para malaman ng AI kung saan ang \"dito\", sinasabi rin sa kaniya ang kasalukuyang township at mga naka-save na township sa app (pangalan at code lang).", + "aiIntroCautionTitle": "Maaaring magkamali ang AI", + "aiIntroCautionBody": "Para sa sanggunian lang ang mga sagot. Kapag maaaring may panganib, protektahan muna ang sarili at sundin ang opisyal na babala at tagubilin ng Central Weather Administration at ng pamahalaan.", + "aiIntroMcpTitle": "Gumawa ng sarili mo", + "aiIntroMcpBody": "Bukas din ang datos ng DPIP bilang MCP server — read-only, walang key na kailangan. Ikonekta ito sa Claude, ChatGPT o anumang tool na may MCP at gumawa ng sarili mong app.", + "aiIntroStart": "Simulan ang setup", + "aiMcpTitle": "DPIP MCP server", + "aiMcpBody": "Magagamit agad ang address na ito ng anumang MCP client na sumusuporta sa Streamable HTTP.", + "aiMcpCopyUrl": "Kopyahin ang URL", + "aiMcpCopyConfig": "Kopyahin ang halimbawang config", + "aiMcpCopied": "Nakopya", + "aiSettingsTitle": "Settings ng AI", + "aiSettingsService": "AI service", + "aiSettingsServiceBody": "Gumagana ang anumang serbisyong compatible sa OpenAI (/chat/completions) na may tool calls. I-tap ang isang serbisyo sa ibaba para ilagay ang URL nito.", + "aiSettingsBaseUrl": "API URL", + "aiSettingsBaseUrlHelper": "Ang base URL ng API, karaniwang nagtatapos sa /v1.", + "aiSettingsUrlEmpty": "Maglagay ng URL", + "aiSettingsUrlInvalid": "Hindi ito wastong URL", + "aiSettingsUrlInsecure": "Gumamit ng https://. Pinapayagan lang ang http:// para sa teleponong ito mismo (localhost).", + "aiSettingsApiKey": "API key", + "aiSettingsApiKeyHelper": "Sa key storage lang ng teleponong ito nakatago. Iwanang blangko kung walang key na kailangan ang serbisyo.", + "aiSettingsApiKeySaved": "May naka-save nang key. Iwanang blangko para panatilihin ito.", + "aiSettingsShowKey": "Ipakita ang key", + "aiSettingsHideKey": "Itago ang key", + "aiSettingsRemoveKey": "Burahin ang naka-save na key", + "aiSettingsKeyRemoved": "Nabura ang key", + "aiSettingsModel": "Model", + "aiSettingsModelHelper": "Dapat sumusuporta sa tool calls (function calling). Ipinapakita ng button sa kanan ang mga model ng serbisyo.", + "aiSettingsModelEmpty": "Maglagay o pumili ng model", + "aiSettingsFetchModels": "Ilista ang mga model", + "aiSettingsFilterModels": "Hanapin ang model", + "aiSettingsNoModels": "Walang inililistang model ang serbisyong ito. I-type na lang ang pangalan ng model.", + "aiSettingsSave": "I-save", + "aiSettingsSaved": "Na-save", + "aiSettingsSaveFailed": "Hindi na-save ang key. Pakisubukang muli.", + "aiSettingsPrivacy": "Privacy", + "aiSettingsPrivacyBody": "Nasa key storage ng teleponong ito ang key at hindi ito ina-upload sa ExpTech. Sa pagitan lang ng teleponong ito at ng iyong AI service dumadaan ang usapan, at hindi ito sine-save. Kapag naghahanap ng datos ang AI, ang query lang ang ipinapadala sa data server ng DPIP.", + "aiSettingsForget": "Burahin lahat ng settings ng AI", + "aiSettingsForgetTitle": "Burahin ang settings ng AI?", + "aiSettingsForgetBody": "Buburahin sa teleponong ito ang URL, model at key.", + "aiSettingsForgetConfirm": "Burahin", + "aiSettingsForgotten": "Nabura ang settings ng AI", + "aiToolEarthquake": "datos ng lindol", + "aiToolWeather": "datos ng panahon", + "aiToolTyphoon": "datos ng bagyo", + "aiToolTsunami": "datos ng tsunami", + "aiToolNotices": "abiso sa sakuna", + "aiToolPlaces": "lugar", + "aiToolTimeline": "oras ng datos", + "aiToolOther": "datos", + "aiToolRunning": "Hinahanap ang {subject}…", + "aiToolDone": "Nahanap ang {subject}", + "aiToolFailed": "Hindi mahanap ang {subject}", + "aiErrorUnauthorized": "Hindi wasto o expired ang API key, o wala itong access sa model na ito.", + "aiErrorNotFound": "Hindi nahanap ang API o model. Suriin ang URL at pangalan ng model.", + "aiErrorRateLimited": "Naabot na ang rate limit o quota ng iyong AI service. Subukang muli mamaya o suriin ang iyong plan.", + "aiErrorRejected": "Tinanggihan ng AI service ang request. Maaaring hindi sumusuporta sa tool calls ang model.", + "aiErrorServer": "Nagkaproblema ang AI service. Subukang muli mamaya.", + "aiErrorNetwork": "Hindi maabot ang AI service. Suriin ang iyong koneksyon.", + "aiErrorTimeout": "Masyadong natagalan ang AI service sa pagsagot.", + "aiErrorBadResponse": "Hindi mabasa ang sagot ng AI service. Maaaring hindi API na compatible sa OpenAI ang URL na ito.", + "aiErrorToolsUnavailable": "Hindi maabot ang data server ng DPIP. Subukang muli mamaya.", + "aiErrorTooManySteps": "Masyadong maraming beses naghanap ng datos ang AI nang walang sagot. Subukan ang mas tiyak na tanong.", + "aiErrorServiceSaid": "Sabi ng serbisyo: {detail}" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 10507b645..d6f480bd6 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "Menerapkan…", "onboardingNotifyTitle": "Peringatan Anda", "onboardingNotifyBody": "Pilih titik awal. Anda dapat menyesuaikan setiap saluran di Pengaturan notifikasi kapan saja, dan setiap pilihan tetap menyalakan peringatan gempa darurat.", - "onboardingNotifySkip": "Lewati dulu" + "onboardingNotifySkip": "Lewati dulu", + "aiChatTitle": "Asisten AI", + "aiChatNew": "Percakapan baru", + "aiChatAbout": "Tentang", + "aiChatInputHint": "Tanya soal gempa, cuaca, atau topan…", + "aiChatSend": "Kirim", + "aiChatStop": "Hentikan", + "aiChatDisclaimer": "AI bisa salah. Saat darurat, ikuti peringatan resmi.", + "aiChatEmptyTitle": "Ingin tahu apa?", + "aiChatSuggestionQuakes": "Ada gempa akhir-akhir ini?", + "aiChatSuggestionRain": "Apakah di sini akan hujan hari ini?", + "aiChatSuggestionTyphoon": "Apakah sekarang ada topan?", + "aiChatSuggestionAlerts": "Peringatan bencana apa yang sedang berlaku?", + "aiChatThinking": "Sedang berpikir…", + "aiChatWaiting": "Menunggu balasan…", + "aiChatStopped": "Dihentikan", + "aiChatOpenSettings": "Buka pengaturan", + "aiChatCopied": "Disalin", + "aiChatSetupTitle": "Layanan AI belum diatur", + "aiChatSetupBody": "Masukkan URL, kunci, dan model layanan AI Anda untuk mulai.", + "aiChatSetupAction": "Atur", + "aiIntroTitle": "Asisten AI", + "aiIntroBody": "Tanyakan gempa terbaru, cuaca besok, atau jalur topan. AI akan mencari data DPIP secara real-time lalu menjawab dalam bahasa Anda.", + "aiIntroOwnKeyTitle": "Gunakan layanan AI Anda sendiri", + "aiIntroOwnKeyBody": "Masukkan URL dan kunci layanan AI yang kompatibel dengan OpenAI dan mendukung pemanggilan alat, seperti OpenAI, Google Gemini, Anthropic, atau OpenRouter. Pemakaian dan biaya dihitung oleh layanan AI Anda.", + "aiIntroLocalTitle": "Kunci hanya ada di ponsel ini; percakapan tidak disimpan", + "aiIntroLocalBody": "Kunci API disimpan di penyimpanan kunci sistem (iOS Keychain, Android Keystore), tidak pernah diunggah ke ExpTech, dan tidak ikut pindah ke ponsel lain lewat cadangan. Percakapan tidak disimpan dan hilang saat aplikasi ditutup.", + "aiIntroDataTitle": "Ke mana data dikirim", + "aiIntroDataBody": "Pertanyaan Anda dikirim langsung dari ponsel ke layanan AI yang Anda atur, tidak melalui ExpTech. Saat AI butuh data, aplikasi menanyakan server data publik DPIP dan hanya mengirim kriteria pencarian (seperti kode wilayah atau tanggal), tanpa kunci atau percakapan. Agar AI tahu di mana \"di sini\", lokasi saat ini dan kota yang Anda simpan di aplikasi (hanya nama dan kode) juga diberitahukan ke AI.", + "aiIntroCautionTitle": "AI bisa salah", + "aiIntroCautionBody": "Jawabannya hanya sebagai referensi. Jika mungkin ada bahaya, lindungi diri Anda terlebih dahulu dan ikuti peringatan serta instruksi resmi dari Central Weather Administration dan pemerintah.", + "aiIntroMcpTitle": "Buat sendiri", + "aiIntroMcpBody": "Data DPIP juga terbuka sebagai server MCP — hanya baca, tanpa kunci. Hubungkan ke Claude, ChatGPT, atau alat lain yang mendukung MCP dan buat sesuatu milik Anda sendiri.", + "aiIntroStart": "Mulai atur", + "aiMcpTitle": "Server MCP DPIP", + "aiMcpBody": "Klien MCP apa pun yang mendukung Streamable HTTP dapat langsung memakai alamat ini.", + "aiMcpCopyUrl": "Salin URL", + "aiMcpCopyConfig": "Salin contoh konfigurasi", + "aiMcpCopied": "Disalin", + "aiSettingsTitle": "Pengaturan AI", + "aiSettingsService": "Layanan AI", + "aiSettingsServiceBody": "Layanan apa pun yang kompatibel dengan OpenAI (/chat/completions) dan mendukung pemanggilan alat bisa dipakai. Ketuk layanan di bawah untuk mengisi URL-nya.", + "aiSettingsBaseUrl": "URL API", + "aiSettingsBaseUrlHelper": "URL dasar API, biasanya diakhiri /v1.", + "aiSettingsUrlEmpty": "Masukkan URL", + "aiSettingsUrlInvalid": "URL tidak valid", + "aiSettingsUrlInsecure": "Gunakan https://. http:// hanya diizinkan untuk ponsel ini sendiri (localhost).", + "aiSettingsApiKey": "Kunci API", + "aiSettingsApiKeyHelper": "Hanya disimpan di penyimpanan kunci ponsel ini. Kosongkan jika layanan tidak memerlukan kunci.", + "aiSettingsApiKeySaved": "Kunci sudah tersimpan. Kosongkan untuk tetap memakainya.", + "aiSettingsShowKey": "Tampilkan kunci", + "aiSettingsHideKey": "Sembunyikan kunci", + "aiSettingsRemoveKey": "Hapus kunci tersimpan", + "aiSettingsKeyRemoved": "Kunci dihapus", + "aiSettingsModel": "Model", + "aiSettingsModelHelper": "Harus mendukung pemanggilan alat (function calling). Tombol di kanan menampilkan daftar model layanan.", + "aiSettingsModelEmpty": "Masukkan atau pilih model", + "aiSettingsFetchModels": "Tampilkan daftar model", + "aiSettingsFilterModels": "Cari model", + "aiSettingsNoModels": "Layanan ini tidak menampilkan model. Ketik nama modelnya langsung.", + "aiSettingsSave": "Simpan", + "aiSettingsSaved": "Tersimpan", + "aiSettingsSaveFailed": "Kunci tidak dapat disimpan. Coba lagi.", + "aiSettingsPrivacy": "Privasi", + "aiSettingsPrivacyBody": "Kunci tetap di penyimpanan kunci ponsel ini dan tidak pernah diunggah ke ExpTech. Percakapan hanya berlangsung antara ponsel ini dan layanan AI Anda, dan tidak disimpan. Saat AI mencari data, hanya kriteria pencarian yang dikirim ke server data DPIP.", + "aiSettingsForget": "Hapus semua pengaturan AI", + "aiSettingsForgetTitle": "Hapus pengaturan AI?", + "aiSettingsForgetBody": "URL, model, dan kunci akan dihapus dari ponsel ini.", + "aiSettingsForgetConfirm": "Hapus", + "aiSettingsForgotten": "Pengaturan AI dihapus", + "aiToolEarthquake": "data gempa", + "aiToolWeather": "data cuaca", + "aiToolTyphoon": "data topan", + "aiToolTsunami": "data tsunami", + "aiToolNotices": "pemberitahuan bencana", + "aiToolPlaces": "lokasi", + "aiToolTimeline": "waktu data", + "aiToolOther": "data", + "aiToolRunning": "Mencari {subject}…", + "aiToolDone": "Selesai mencari {subject}", + "aiToolFailed": "Gagal mencari {subject}", + "aiErrorUnauthorized": "Kunci API tidak valid atau kedaluwarsa, atau tidak punya akses ke model ini.", + "aiErrorNotFound": "API atau model tidak ditemukan. Periksa URL dan nama model.", + "aiErrorRateLimited": "Batas laju atau kuota layanan AI Anda tercapai. Coba lagi nanti atau periksa paket Anda.", + "aiErrorRejected": "Layanan AI menolak permintaan. Model mungkin tidak mendukung pemanggilan alat.", + "aiErrorServer": "Layanan AI sedang bermasalah. Coba lagi nanti.", + "aiErrorNetwork": "Tidak dapat menjangkau layanan AI. Periksa koneksi Anda.", + "aiErrorTimeout": "Layanan AI terlalu lama merespons.", + "aiErrorBadResponse": "Balasan layanan AI tidak dapat dibaca. URL ini mungkin bukan API yang kompatibel dengan OpenAI.", + "aiErrorToolsUnavailable": "Tidak dapat menjangkau server data DPIP. Coba lagi nanti.", + "aiErrorTooManySteps": "AI mencari data terlalu banyak kali tanpa jawaban. Coba pertanyaan yang lebih spesifik.", + "aiErrorServiceSaid": "Pesan layanan: {detail}" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 16da7ed47..2801a6f53 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "適用しています…", "onboardingNotifyTitle": "通知の受け取り方", "onboardingNotifyBody": "まずは出発点を選んでください。各チャンネルは「通知設定」でいつでも個別に調整でき、どの選択肢でも緊急地震速報は受け取れます。", - "onboardingNotifySkip": "あとで設定する" + "onboardingNotifySkip": "あとで設定する", + "aiChatTitle": "AIアシスタント", + "aiChatNew": "新しい会話", + "aiChatAbout": "説明", + "aiChatInputHint": "地震・天気・台風について質問…", + "aiChatSend": "送信", + "aiChatStop": "停止", + "aiChatDisclaimer": "AIは間違えることがあります。緊急時は公式の警報に従ってください。", + "aiChatEmptyTitle": "何を知りたいですか?", + "aiChatSuggestionQuakes": "最近地震はありましたか?", + "aiChatSuggestionRain": "今日ここは雨が降りますか?", + "aiChatSuggestionTyphoon": "今、台風はありますか?", + "aiChatSuggestionAlerts": "現在出ている災害情報は?", + "aiChatThinking": "考え中…", + "aiChatWaiting": "応答を待っています…", + "aiChatStopped": "停止しました", + "aiChatOpenSettings": "設定を開く", + "aiChatCopied": "コピーしました", + "aiChatSetupTitle": "AIサービスが未設定です", + "aiChatSetupBody": "自分のAIサービスのURL、APIキー、モデルを入力すると使えます。", + "aiChatSetupAction": "設定する", + "aiIntroTitle": "AIアシスタント", + "aiIntroBody": "最近の地震、明日の天気、台風の進路などを質問できます。AIはまずDPIPのデータをリアルタイムで調べてから、あなたの言語で答えます。", + "aiIntroOwnKeyTitle": "自分のAIサービスを使います", + "aiIntroOwnKeyBody": "ツール呼び出しに対応した OpenAI 互換の AI サービス(OpenAI、Google Gemini、Anthropic、OpenRouter など)の URL と API キーを入力します。利用量と料金はそのサービス側で計算されます。", + "aiIntroLocalTitle": "キーはこの端末にだけ保存され、会話は保存されません", + "aiIntroLocalBody": "APIキーはシステムのキー保管庫(iOSのキーチェーン、AndroidのKeystore)に保存され、ExpTechにアップロードされることはなく、バックアップで別の端末に移ることもありません。会話は保存されず、アプリを閉じると消えます。", + "aiIntroDataTitle": "データの流れ", + "aiIntroDataBody": "質問はこの端末から、設定したAIサービスへ直接送られ、ExpTechを経由しません。AIがデータを必要とするときは、アプリがDPIPの公開データサーバーに問い合わせますが、送るのは検索条件(地域コードや日付など)だけで、キーや会話は含みません。「ここ」がどこかを伝えるため、アプリに登録した現在地と保存した郷鎮(名称とコードのみ)もAIに伝えます。", + "aiIntroCautionTitle": "AIは間違えることがあります", + "aiIntroCautionBody": "回答は参考情報です。危険がありそうなときは、まず身の安全を確保し、中央気象署や政府の公式の警報・指示に従ってください。", + "aiIntroMcpTitle": "自分で作る", + "aiIntroMcpBody": "DPIPのデータはMCPサーバーとしても公開されています(読み取り専用、キー不要)。Claude、ChatGPT など MCP 対応ツールにつないで、自分だけの使い方ができます。", + "aiIntroStart": "設定を始める", + "aiMcpTitle": "DPIP MCPサーバー", + "aiMcpBody": "Streamable HTTP に対応した MCP クライアントなら、このアドレスをそのまま使えます。", + "aiMcpCopyUrl": "URLをコピー", + "aiMcpCopyConfig": "設定例をコピー", + "aiMcpCopied": "コピーしました", + "aiSettingsTitle": "AI設定", + "aiSettingsService": "AIサービス", + "aiSettingsServiceBody": "ツール呼び出しに対応した OpenAI 互換サービス(/chat/completions)なら使えます。下のサービスをタップすると URL が入力されます。", + "aiSettingsBaseUrl": "API の URL", + "aiSettingsBaseUrlHelper": "APIのベースURL。通常は /v1 で終わります。", + "aiSettingsUrlEmpty": "URLを入力してください", + "aiSettingsUrlInvalid": "有効なURLではありません", + "aiSettingsUrlInsecure": "https:// を使ってください。http:// はこの端末自身(localhost)にだけ使えます。", + "aiSettingsApiKey": "APIキー", + "aiSettingsApiKeyHelper": "この端末のキー保管庫にのみ保存されます。キーが不要なサービスでは空欄のままにしてください。", + "aiSettingsApiKeySaved": "キーは保存済みです。空欄のままにすると今のキーを使い続けます。", + "aiSettingsShowKey": "キーを表示", + "aiSettingsHideKey": "キーを隠す", + "aiSettingsRemoveKey": "保存したキーを削除", + "aiSettingsKeyRemoved": "キーを削除しました", + "aiSettingsModel": "モデル", + "aiSettingsModelHelper": "ツール呼び出し(function calling)に対応したモデルが必要です。右のボタンでモデル一覧を取得できます。", + "aiSettingsModelEmpty": "モデルを入力または選択してください", + "aiSettingsFetchModels": "モデル一覧を取得", + "aiSettingsFilterModels": "モデルを検索", + "aiSettingsNoModels": "このサービスはモデルを公開していません。モデル名を直接入力してください。", + "aiSettingsSave": "保存", + "aiSettingsSaved": "保存しました", + "aiSettingsSaveFailed": "キーを保存できませんでした。もう一度お試しください。", + "aiSettingsPrivacy": "プライバシー", + "aiSettingsPrivacyBody": "キーはこの端末のキー保管庫にのみ保存され、ExpTechにアップロードされることはありません。会話はこの端末とあなたのAIサービスの間だけでやり取りされ、保存されません。AIがデータを調べるときは、検索条件だけがDPIPのデータサーバーに送られます。", + "aiSettingsForget": "AI設定をすべて消去", + "aiSettingsForgetTitle": "AI設定を消去しますか?", + "aiSettingsForgetBody": "URL、モデル、キーがこの端末から削除されます。", + "aiSettingsForgetConfirm": "消去", + "aiSettingsForgotten": "AI設定を消去しました", + "aiToolEarthquake": "地震情報", + "aiToolWeather": "気象情報", + "aiToolTyphoon": "台風情報", + "aiToolTsunami": "津波情報", + "aiToolNotices": "災害情報", + "aiToolPlaces": "地点", + "aiToolTimeline": "データの時刻", + "aiToolOther": "データ", + "aiToolRunning": "{subject}を調べています…", + "aiToolDone": "{subject}を調べました", + "aiToolFailed": "{subject}を取得できませんでした", + "aiErrorUnauthorized": "APIキーが無効か期限切れ、またはこのモデルを使う権限がありません。", + "aiErrorNotFound": "APIまたはモデルが見つかりません。URLとモデル名を確認してください。", + "aiErrorRateLimited": "AIサービスのレート制限または利用枠の上限に達しました。しばらくしてから再試行するか、プランを確認してください。", + "aiErrorRejected": "AIサービスがリクエストを拒否しました。モデルがツール呼び出しに対応していない可能性があります。", + "aiErrorServer": "AIサービスで一時的なエラーが発生しました。しばらくしてから再試行してください。", + "aiErrorNetwork": "AIサービスに接続できません。ネットワークを確認してください。", + "aiErrorTimeout": "AIサービスの応答に時間がかかりすぎています。", + "aiErrorBadResponse": "AIサービスの応答を読み取れませんでした。このURLはOpenAI互換のAPIではない可能性があります。", + "aiErrorToolsUnavailable": "DPIPのデータサーバーに接続できません。しばらくしてから再試行してください。", + "aiErrorTooManySteps": "AIがデータを何度も調べましたが答えが出ませんでした。もっと具体的に質問してください。", + "aiErrorServiceSaid": "サービスからのメッセージ:{detail}" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 1ab9cef8b..7a1bf3ea1 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "적용하는 중…", "onboardingNotifyTitle": "알림 받기", "onboardingNotifyBody": "먼저 시작점을 골라 주세요. 각 채널은 언제든 '알림 설정'에서 조정할 수 있으며, 어떤 옵션이든 긴급 지진 경보는 계속 받습니다.", - "onboardingNotifySkip": "나중에 설정" + "onboardingNotifySkip": "나중에 설정", + "aiChatTitle": "AI 어시스턴트", + "aiChatNew": "새 대화", + "aiChatAbout": "안내", + "aiChatInputHint": "지진, 날씨, 태풍에 대해 물어보세요…", + "aiChatSend": "보내기", + "aiChatStop": "중지", + "aiChatDisclaimer": "AI는 틀릴 수 있습니다. 긴급 시에는 공식 경보를 따르세요.", + "aiChatEmptyTitle": "무엇이 궁금하세요?", + "aiChatSuggestionQuakes": "최근에 지진이 있었나요?", + "aiChatSuggestionRain": "오늘 여기 비가 올까요?", + "aiChatSuggestionTyphoon": "지금 태풍이 있나요?", + "aiChatSuggestionAlerts": "현재 발효 중인 재난 경보는?", + "aiChatThinking": "생각 중…", + "aiChatWaiting": "응답을 기다리는 중…", + "aiChatStopped": "중지됨", + "aiChatOpenSettings": "설정 열기", + "aiChatCopied": "복사했습니다", + "aiChatSetupTitle": "AI 서비스가 설정되지 않았습니다", + "aiChatSetupBody": "사용 중인 AI 서비스의 URL, 키, 모델을 입력하면 시작할 수 있습니다.", + "aiChatSetupAction": "설정하기", + "aiIntroTitle": "AI 어시스턴트", + "aiIntroBody": "최근 지진, 내일 날씨, 태풍 진로 등을 물어보세요. AI가 먼저 DPIP 데이터를 실시간으로 조회한 뒤 사용자의 언어로 답합니다.", + "aiIntroOwnKeyTitle": "자신의 AI 서비스를 사용합니다", + "aiIntroOwnKeyBody": "도구 호출을 지원하는 OpenAI 호환 AI 서비스(OpenAI, Google Gemini, Anthropic, OpenRouter 등)의 URL과 키를 입력하세요. 사용량과 요금은 해당 AI 서비스에서 계산됩니다.", + "aiIntroLocalTitle": "키는 이 기기에만 저장되고, 대화는 저장되지 않습니다", + "aiIntroLocalBody": "API 키는 시스템 키 저장소(iOS 키체인, Android Keystore)에 보관되며 ExpTech에 업로드되지 않고, 백업을 통해 다른 기기로 옮겨지지도 않습니다. 대화는 저장되지 않으며 앱을 닫으면 사라집니다.", + "aiIntroDataTitle": "데이터 전송 방식", + "aiIntroDataBody": "질문은 이 기기에서 설정한 AI 서비스로 바로 전송되며 ExpTech를 거치지 않습니다. AI에 데이터가 필요하면 앱이 DPIP 공개 데이터 서버에 조회하며, 조회 조건(지역 코드나 날짜 등)만 보내고 키나 대화 내용은 보내지 않습니다. ‘여기’가 어디인지 알 수 있도록 앱에 설정된 현재 위치와 저장한 향진(이름과 코드만)을 AI에 알려 줍니다.", + "aiIntroCautionTitle": "AI는 틀릴 수 있습니다", + "aiIntroCautionBody": "답변은 참고용입니다. 위험할 수 있을 때는 먼저 자신을 보호하고, 중앙기상서와 정부의 공식 경보와 지시를 따르세요.", + "aiIntroMcpTitle": "직접 만들어 보기", + "aiIntroMcpBody": "DPIP 데이터는 MCP 서버로도 공개되어 있습니다(읽기 전용, 키 불필요). Claude, ChatGPT 등 MCP를 지원하는 도구에 연결해 나만의 활용법을 만들어 보세요.", + "aiIntroStart": "설정 시작", + "aiMcpTitle": "DPIP MCP 서버", + "aiMcpBody": "Streamable HTTP를 지원하는 MCP 클라이언트라면 이 주소를 바로 사용할 수 있습니다.", + "aiMcpCopyUrl": "URL 복사", + "aiMcpCopyConfig": "설정 예시 복사", + "aiMcpCopied": "복사했습니다", + "aiSettingsTitle": "AI 설정", + "aiSettingsService": "AI 서비스", + "aiSettingsServiceBody": "도구 호출을 지원하는 OpenAI 호환 서비스(/chat/completions)라면 사용할 수 있습니다. 아래 서비스를 누르면 URL이 입력됩니다.", + "aiSettingsBaseUrl": "API URL", + "aiSettingsBaseUrlHelper": "API의 기본 URL로, 보통 /v1로 끝납니다.", + "aiSettingsUrlEmpty": "URL을 입력하세요", + "aiSettingsUrlInvalid": "올바른 URL이 아닙니다", + "aiSettingsUrlInsecure": "https://를 사용하세요. http://는 이 기기 자체(localhost)에만 사용할 수 있습니다.", + "aiSettingsApiKey": "API 키", + "aiSettingsApiKeyHelper": "이 기기의 키 저장소에만 저장됩니다. 키가 필요 없는 서비스라면 비워 두세요.", + "aiSettingsApiKeySaved": "키가 저장되어 있습니다. 비워 두면 기존 키를 유지합니다.", + "aiSettingsShowKey": "키 표시", + "aiSettingsHideKey": "키 숨기기", + "aiSettingsRemoveKey": "저장된 키 삭제", + "aiSettingsKeyRemoved": "키를 삭제했습니다", + "aiSettingsModel": "모델", + "aiSettingsModelHelper": "도구 호출(function calling)을 지원하는 모델이어야 합니다. 오른쪽 버튼으로 모델 목록을 불러올 수 있습니다.", + "aiSettingsModelEmpty": "모델을 입력하거나 선택하세요", + "aiSettingsFetchModels": "모델 목록 가져오기", + "aiSettingsFilterModels": "모델 검색", + "aiSettingsNoModels": "이 서비스는 모델 목록을 제공하지 않습니다. 모델 이름을 직접 입력하세요.", + "aiSettingsSave": "저장", + "aiSettingsSaved": "저장했습니다", + "aiSettingsSaveFailed": "키를 저장하지 못했습니다. 다시 시도하세요.", + "aiSettingsPrivacy": "개인정보", + "aiSettingsPrivacyBody": "키는 이 기기의 키 저장소에만 보관되며 ExpTech에 업로드되지 않습니다. 대화는 이 기기와 AI 서비스 사이에서만 오가며 저장되지 않습니다. AI가 데이터를 조회할 때는 조회 조건만 DPIP 데이터 서버로 전송됩니다.", + "aiSettingsForget": "AI 설정 모두 지우기", + "aiSettingsForgetTitle": "AI 설정을 지울까요?", + "aiSettingsForgetBody": "이 기기에서 URL, 모델, 키가 삭제됩니다.", + "aiSettingsForgetConfirm": "지우기", + "aiSettingsForgotten": "AI 설정을 지웠습니다", + "aiToolEarthquake": "지진 정보", + "aiToolWeather": "날씨 정보", + "aiToolTyphoon": "태풍 정보", + "aiToolTsunami": "쓰나미 정보", + "aiToolNotices": "재난 알림", + "aiToolPlaces": "지역", + "aiToolTimeline": "데이터 시각", + "aiToolOther": "데이터", + "aiToolRunning": "{subject} 조회 중…", + "aiToolDone": "{subject} 조회 완료", + "aiToolFailed": "{subject} 조회 실패", + "aiErrorUnauthorized": "API 키가 잘못되었거나 만료되었거나, 이 모델을 사용할 권한이 없습니다.", + "aiErrorNotFound": "API 또는 모델을 찾을 수 없습니다. URL과 모델 이름을 확인하세요.", + "aiErrorRateLimited": "AI 서비스의 요청 한도나 사용량 한도에 도달했습니다. 잠시 후 다시 시도하거나 요금제를 확인하세요.", + "aiErrorRejected": "AI 서비스가 요청을 거부했습니다. 선택한 모델이 도구 호출을 지원하지 않을 수 있습니다.", + "aiErrorServer": "AI 서비스에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도하세요.", + "aiErrorNetwork": "AI 서비스에 연결할 수 없습니다. 네트워크를 확인하세요.", + "aiErrorTimeout": "AI 서비스의 응답이 너무 오래 걸립니다.", + "aiErrorBadResponse": "AI 서비스의 응답을 읽을 수 없습니다. 이 URL은 OpenAI 호환 API가 아닐 수 있습니다.", + "aiErrorToolsUnavailable": "DPIP 데이터 서버에 연결할 수 없습니다. 잠시 후 다시 시도하세요.", + "aiErrorTooManySteps": "AI가 데이터를 너무 여러 번 조회했지만 답을 내지 못했습니다. 더 구체적으로 질문해 보세요.", + "aiErrorServiceSaid": "서비스 메시지: {detail}" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 06ddb9ab2..db0b94a54 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "กำลังใช้…", "onboardingNotifyTitle": "การแจ้งเตือนของคุณ", "onboardingNotifyBody": "เลือกจุดเริ่มต้นก่อน คุณปรับแต่ละช่องได้ทุกเมื่อใน “การตั้งค่าการแจ้งเตือน” และทุกตัวเลือกยังคงรับการเตือนแผ่นดินไหวฉุกเฉิน", - "onboardingNotifySkip": "ข้ามไปก่อน" + "onboardingNotifySkip": "ข้ามไปก่อน", + "aiChatTitle": "ผู้ช่วย AI", + "aiChatNew": "บทสนทนาใหม่", + "aiChatAbout": "เกี่ยวกับ", + "aiChatInputHint": "ถามเรื่องแผ่นดินไหว สภาพอากาศ หรือพายุไต้ฝุ่น…", + "aiChatSend": "ส่ง", + "aiChatStop": "หยุด", + "aiChatDisclaimer": "AI อาจผิดพลาดได้ ในกรณีฉุกเฉินให้ยึดตามประกาศเตือนภัยอย่างเป็นทางการ", + "aiChatEmptyTitle": "อยากรู้อะไร?", + "aiChatSuggestionQuakes": "ช่วงนี้มีแผ่นดินไหวไหม?", + "aiChatSuggestionRain": "วันนี้ที่นี่ฝนจะตกไหม?", + "aiChatSuggestionTyphoon": "ตอนนี้มีพายุไต้ฝุ่นไหม?", + "aiChatSuggestionAlerts": "ตอนนี้มีประกาศเตือนภัยอะไรบ้าง?", + "aiChatThinking": "กำลังคิด…", + "aiChatWaiting": "กำลังรอคำตอบ…", + "aiChatStopped": "หยุดแล้ว", + "aiChatOpenSettings": "เปิดการตั้งค่า", + "aiChatCopied": "คัดลอกแล้ว", + "aiChatSetupTitle": "ยังไม่ได้ตั้งค่าบริการ AI", + "aiChatSetupBody": "กรอก URL คีย์ และโมเดลของบริการ AI ของคุณเพื่อเริ่มใช้งาน", + "aiChatSetupAction": "ไปที่การตั้งค่า", + "aiIntroTitle": "ผู้ช่วย AI", + "aiIntroBody": "ถามเรื่องแผ่นดินไหวล่าสุด สภาพอากาศพรุ่งนี้ หรือเส้นทางพายุไต้ฝุ่นได้ AI จะค้นข้อมูลจาก DPIP แบบเรียลไทม์ก่อน แล้วตอบเป็นภาษาของคุณ", + "aiIntroOwnKeyTitle": "ใช้บริการ AI ของคุณเอง", + "aiIntroOwnKeyBody": "กรอก URL และคีย์ของบริการ AI ที่รองรับรูปแบบ OpenAI และการเรียกใช้เครื่องมือ เช่น OpenAI, Google Gemini, Anthropic หรือ OpenRouter ปริมาณการใช้และค่าใช้จ่ายคิดโดยบริการ AI ของคุณ", + "aiIntroLocalTitle": "คีย์อยู่ในโทรศัพท์เครื่องนี้เท่านั้น และบทสนทนาจะไม่ถูกบันทึก", + "aiIntroLocalBody": "คีย์ API ถูกเก็บในที่เก็บคีย์ของระบบ (iOS Keychain, Android Keystore) ไม่ถูกอัปโหลดไปยัง ExpTech และจะไม่ถูกย้ายไปยังโทรศัพท์เครื่องอื่นผ่านการสำรองข้อมูล บทสนทนาจะไม่ถูกบันทึกและจะหายไปเมื่อปิดแอป", + "aiIntroDataTitle": "ข้อมูลถูกส่งไปที่ไหน", + "aiIntroDataBody": "คำถามของคุณถูกส่งจากโทรศัพท์ไปยังบริการ AI ที่คุณตั้งค่าไว้โดยตรง ไม่ผ่าน ExpTech เมื่อ AI ต้องการข้อมูล แอปจะสอบถามเซิร์ฟเวอร์ข้อมูลสาธารณะของ DPIP โดยส่งเพียงเงื่อนไขการค้นหา (เช่น รหัสพื้นที่หรือวันที่) ไม่รวมคีย์และบทสนทนา เพื่อให้ AI รู้ว่า \"ที่นี่\" คือที่ไหน แอปจะบอกตำบลปัจจุบันและตำบลที่คุณบันทึกไว้ (เฉพาะชื่อและรหัส) ให้ AI ทราบ", + "aiIntroCautionTitle": "AI อาจผิดพลาดได้", + "aiIntroCautionBody": "คำตอบมีไว้เพื่อการอ้างอิงเท่านั้น หากอาจมีอันตราย ให้ปกป้องตัวเองก่อน และปฏิบัติตามประกาศเตือนภัยและคำแนะนำอย่างเป็นทางการจากสำนักงานอุตุนิยมวิทยากลางและหน่วยงานรัฐ", + "aiIntroMcpTitle": "สร้างเองได้", + "aiIntroMcpBody": "ข้อมูลของ DPIP ยังเปิดให้ใช้ในรูปแบบเซิร์ฟเวอร์ MCP (อ่านอย่างเดียว ไม่ต้องใช้คีย์) เชื่อมต่อกับ Claude, ChatGPT หรือเครื่องมืออื่นที่รองรับ MCP เพื่อสร้างสิ่งของคุณเองได้", + "aiIntroStart": "เริ่มตั้งค่า", + "aiMcpTitle": "เซิร์ฟเวอร์ MCP ของ DPIP", + "aiMcpBody": "ไคลเอนต์ MCP ที่รองรับ Streamable HTTP ใช้ที่อยู่นี้ได้ทันที", + "aiMcpCopyUrl": "คัดลอก URL", + "aiMcpCopyConfig": "คัดลอกตัวอย่างการตั้งค่า", + "aiMcpCopied": "คัดลอกแล้ว", + "aiSettingsTitle": "การตั้งค่า AI", + "aiSettingsService": "บริการ AI", + "aiSettingsServiceBody": "ใช้ได้กับบริการที่รองรับรูปแบบ OpenAI (/chat/completions) และการเรียกใช้เครื่องมือ แตะบริการด้านล่างเพื่อกรอก URL ให้อัตโนมัติ", + "aiSettingsBaseUrl": "URL ของ API", + "aiSettingsBaseUrlHelper": "URL หลักของ API โดยทั่วไปลงท้ายด้วย /v1", + "aiSettingsUrlEmpty": "กรุณากรอก URL", + "aiSettingsUrlInvalid": "URL ไม่ถูกต้อง", + "aiSettingsUrlInsecure": "กรุณาใช้ https:// ใช้ http:// ได้เฉพาะกับโทรศัพท์เครื่องนี้เอง (localhost)", + "aiSettingsApiKey": "คีย์ API", + "aiSettingsApiKeyHelper": "เก็บไว้ในที่เก็บคีย์ของโทรศัพท์เครื่องนี้เท่านั้น เว้นว่างได้หากบริการไม่ต้องใช้คีย์", + "aiSettingsApiKeySaved": "บันทึกคีย์แล้ว เว้นว่างไว้เพื่อใช้คีย์เดิม", + "aiSettingsShowKey": "แสดงคีย์", + "aiSettingsHideKey": "ซ่อนคีย์", + "aiSettingsRemoveKey": "ลบคีย์ที่บันทึกไว้", + "aiSettingsKeyRemoved": "ลบคีย์แล้ว", + "aiSettingsModel": "โมเดล", + "aiSettingsModelHelper": "ต้องเป็นโมเดลที่รองรับการเรียกใช้เครื่องมือ (function calling) แตะปุ่มด้านขวาเพื่อดูรายการโมเดล", + "aiSettingsModelEmpty": "กรุณากรอกหรือเลือกโมเดล", + "aiSettingsFetchModels": "ดูรายการโมเดล", + "aiSettingsFilterModels": "ค้นหาโมเดล", + "aiSettingsNoModels": "บริการนี้ไม่ได้แสดงรายการโมเดล กรุณาพิมพ์ชื่อโมเดลเอง", + "aiSettingsSave": "บันทึก", + "aiSettingsSaved": "บันทึกแล้ว", + "aiSettingsSaveFailed": "บันทึกคีย์ไม่สำเร็จ โปรดลองอีกครั้ง", + "aiSettingsPrivacy": "ความเป็นส่วนตัว", + "aiSettingsPrivacyBody": "คีย์อยู่ในที่เก็บคีย์ของโทรศัพท์เครื่องนี้และไม่ถูกอัปโหลดไปยัง ExpTech บทสนทนาส่งระหว่างโทรศัพท์เครื่องนี้กับบริการ AI ของคุณเท่านั้นและไม่ถูกบันทึก เมื่อ AI ค้นข้อมูล จะส่งเพียงเงื่อนไขการค้นหาไปยังเซิร์ฟเวอร์ข้อมูลของ DPIP", + "aiSettingsForget": "ล้างการตั้งค่า AI ทั้งหมด", + "aiSettingsForgetTitle": "ล้างการตั้งค่า AI ไหม?", + "aiSettingsForgetBody": "URL โมเดล และคีย์จะถูกลบออกจากโทรศัพท์เครื่องนี้", + "aiSettingsForgetConfirm": "ล้าง", + "aiSettingsForgotten": "ล้างการตั้งค่า AI แล้ว", + "aiToolEarthquake": "ข้อมูลแผ่นดินไหว", + "aiToolWeather": "ข้อมูลสภาพอากาศ", + "aiToolTyphoon": "ข้อมูลพายุไต้ฝุ่น", + "aiToolTsunami": "ข้อมูลสึนามิ", + "aiToolNotices": "ประกาศภัยพิบัติ", + "aiToolPlaces": "สถานที่", + "aiToolTimeline": "เวลาของข้อมูล", + "aiToolOther": "ข้อมูล", + "aiToolRunning": "กำลังค้นหา{subject}…", + "aiToolDone": "ค้นหา{subject}แล้ว", + "aiToolFailed": "ค้นหา{subject}ไม่สำเร็จ", + "aiErrorUnauthorized": "คีย์ API ไม่ถูกต้องหรือหมดอายุ หรือไม่มีสิทธิ์ใช้โมเดลนี้", + "aiErrorNotFound": "ไม่พบ API หรือโมเดล กรุณาตรวจสอบ URL และชื่อโมเดล", + "aiErrorRateLimited": "ถึงขีดจำกัดอัตราการใช้หรือโควตาของบริการ AI แล้ว โปรดลองใหม่ภายหลังหรือตรวจสอบแพ็กเกจ", + "aiErrorRejected": "บริการ AI ปฏิเสธคำขอ โมเดลที่เลือกอาจไม่รองรับการเรียกใช้เครื่องมือ", + "aiErrorServer": "บริการ AI ขัดข้องชั่วคราว โปรดลองใหม่ภายหลัง", + "aiErrorNetwork": "เชื่อมต่อบริการ AI ไม่ได้ กรุณาตรวจสอบการเชื่อมต่อ", + "aiErrorTimeout": "บริการ AI ใช้เวลาตอบนานเกินไป", + "aiErrorBadResponse": "อ่านคำตอบของบริการ AI ไม่ได้ URL นี้อาจไม่ใช่ API ที่รองรับรูปแบบ OpenAI", + "aiErrorToolsUnavailable": "เชื่อมต่อเซิร์ฟเวอร์ข้อมูล DPIP ไม่ได้ โปรดลองใหม่ภายหลัง", + "aiErrorTooManySteps": "AI ค้นข้อมูลหลายครั้งเกินไปแต่ยังไม่ได้คำตอบ ลองถามให้เจาะจงขึ้น", + "aiErrorServiceSaid": "ข้อความจากบริการ: {detail}" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index ff1f12830..6d18edeae 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "Đang áp dụng…", "onboardingNotifyTitle": "Cảnh báo của bạn", "onboardingNotifyBody": "Hãy chọn một điểm bắt đầu. Bạn có thể tinh chỉnh từng kênh trong Cài đặt thông báo bất cứ lúc nào, và mọi lựa chọn đều giữ cảnh báo động đất khẩn cấp.", - "onboardingNotifySkip": "Để sau" + "onboardingNotifySkip": "Để sau", + "aiChatTitle": "Trợ lý AI", + "aiChatNew": "Cuộc trò chuyện mới", + "aiChatAbout": "Giới thiệu", + "aiChatInputHint": "Hỏi về động đất, thời tiết hoặc bão…", + "aiChatSend": "Gửi", + "aiChatStop": "Dừng", + "aiChatDisclaimer": "AI có thể sai. Khi khẩn cấp, hãy làm theo cảnh báo chính thức.", + "aiChatEmptyTitle": "Bạn muốn biết điều gì?", + "aiChatSuggestionQuakes": "Gần đây có động đất không?", + "aiChatSuggestionRain": "Hôm nay chỗ tôi có mưa không?", + "aiChatSuggestionTyphoon": "Hiện có bão không?", + "aiChatSuggestionAlerts": "Hiện có những cảnh báo thiên tai nào?", + "aiChatThinking": "Đang suy nghĩ…", + "aiChatWaiting": "Đang chờ phản hồi…", + "aiChatStopped": "Đã dừng", + "aiChatOpenSettings": "Mở cài đặt", + "aiChatCopied": "Đã sao chép", + "aiChatSetupTitle": "Chưa thiết lập dịch vụ AI", + "aiChatSetupBody": "Nhập URL, khóa và mô hình của dịch vụ AI của bạn để bắt đầu.", + "aiChatSetupAction": "Thiết lập", + "aiIntroTitle": "Trợ lý AI", + "aiIntroBody": "Hỏi về các trận động đất gần đây, thời tiết ngày mai hay đường đi của bão. AI sẽ tra dữ liệu DPIP theo thời gian thực trước rồi trả lời bằng ngôn ngữ của bạn.", + "aiIntroOwnKeyTitle": "Dùng dịch vụ AI của riêng bạn", + "aiIntroOwnKeyBody": "Nhập URL và khóa của bất kỳ dịch vụ AI tương thích OpenAI có hỗ trợ gọi công cụ, như OpenAI, Google Gemini, Anthropic hoặc OpenRouter. Lượng sử dụng và chi phí do dịch vụ AI của bạn tính.", + "aiIntroLocalTitle": "Khóa chỉ ở trên điện thoại này; cuộc trò chuyện không được lưu", + "aiIntroLocalBody": "Khóa API được lưu trong kho khóa của hệ thống (iOS Keychain, Android Keystore), không bao giờ được tải lên ExpTech và không theo bản sao lưu sang điện thoại khác. Cuộc trò chuyện không được lưu và sẽ mất khi đóng ứng dụng.", + "aiIntroDataTitle": "Dữ liệu được gửi đi đâu", + "aiIntroDataBody": "Câu hỏi của bạn được gửi thẳng từ điện thoại tới dịch vụ AI bạn thiết lập, không qua ExpTech. Khi AI cần dữ liệu, ứng dụng sẽ hỏi máy chủ dữ liệu công khai của DPIP, chỉ gửi điều kiện truy vấn (như mã khu vực hoặc ngày), không kèm khóa hay cuộc trò chuyện. Để AI biết \"chỗ tôi\" là đâu, vị trí hiện tại và các thị trấn đã lưu trong ứng dụng (chỉ tên và mã) cũng được gửi cho AI.", + "aiIntroCautionTitle": "AI có thể sai", + "aiIntroCautionBody": "Câu trả lời chỉ mang tính tham khảo. Khi có thể gặp nguy hiểm, hãy bảo vệ bản thân trước và làm theo cảnh báo, chỉ dẫn chính thức của Cục Khí tượng Trung ương và chính quyền.", + "aiIntroMcpTitle": "Tự xây dựng", + "aiIntroMcpBody": "Dữ liệu của DPIP cũng được mở dưới dạng máy chủ MCP — chỉ đọc, không cần khóa. Hãy kết nối với Claude, ChatGPT hoặc công cụ hỗ trợ MCP khác để tự xây dựng ứng dụng của bạn.", + "aiIntroStart": "Bắt đầu thiết lập", + "aiMcpTitle": "Máy chủ MCP của DPIP", + "aiMcpBody": "Mọi ứng dụng khách MCP hỗ trợ Streamable HTTP đều dùng được địa chỉ này.", + "aiMcpCopyUrl": "Sao chép URL", + "aiMcpCopyConfig": "Sao chép cấu hình mẫu", + "aiMcpCopied": "Đã sao chép", + "aiSettingsTitle": "Cài đặt AI", + "aiSettingsService": "Dịch vụ AI", + "aiSettingsServiceBody": "Dùng được với mọi dịch vụ tương thích OpenAI (/chat/completions) có hỗ trợ gọi công cụ. Chạm vào một dịch vụ bên dưới để điền URL.", + "aiSettingsBaseUrl": "URL API", + "aiSettingsBaseUrlHelper": "URL gốc của API, thường kết thúc bằng /v1.", + "aiSettingsUrlEmpty": "Hãy nhập URL", + "aiSettingsUrlInvalid": "URL không hợp lệ", + "aiSettingsUrlInsecure": "Hãy dùng https://. Chỉ được dùng http:// cho chính điện thoại này (localhost).", + "aiSettingsApiKey": "Khóa API", + "aiSettingsApiKeyHelper": "Chỉ được lưu trong kho khóa của điện thoại này. Để trống nếu dịch vụ không cần khóa.", + "aiSettingsApiKeySaved": "Đã lưu khóa. Để trống để giữ khóa hiện tại.", + "aiSettingsShowKey": "Hiện khóa", + "aiSettingsHideKey": "Ẩn khóa", + "aiSettingsRemoveKey": "Xóa khóa đã lưu", + "aiSettingsKeyRemoved": "Đã xóa khóa", + "aiSettingsModel": "Mô hình", + "aiSettingsModelHelper": "Cần mô hình hỗ trợ gọi công cụ (function calling). Nút bên phải sẽ liệt kê các mô hình của dịch vụ.", + "aiSettingsModelEmpty": "Hãy nhập hoặc chọn mô hình", + "aiSettingsFetchModels": "Lấy danh sách mô hình", + "aiSettingsFilterModels": "Lọc mô hình", + "aiSettingsNoModels": "Dịch vụ này không liệt kê mô hình nào. Hãy nhập tên mô hình.", + "aiSettingsSave": "Lưu", + "aiSettingsSaved": "Đã lưu", + "aiSettingsSaveFailed": "Không lưu được khóa. Vui lòng thử lại.", + "aiSettingsPrivacy": "Quyền riêng tư", + "aiSettingsPrivacyBody": "Khóa chỉ nằm trong kho khóa của điện thoại này và không bao giờ được tải lên ExpTech. Cuộc trò chuyện chỉ truyền giữa điện thoại này và dịch vụ AI của bạn, và không được lưu. Khi AI tra dữ liệu, chỉ điều kiện truy vấn được gửi tới máy chủ dữ liệu của DPIP.", + "aiSettingsForget": "Xóa toàn bộ cài đặt AI", + "aiSettingsForgetTitle": "Xóa cài đặt AI?", + "aiSettingsForgetBody": "URL, mô hình và khóa sẽ bị xóa khỏi điện thoại này.", + "aiSettingsForgetConfirm": "Xóa", + "aiSettingsForgotten": "Đã xóa cài đặt AI", + "aiToolEarthquake": "dữ liệu động đất", + "aiToolWeather": "dữ liệu thời tiết", + "aiToolTyphoon": "dữ liệu bão", + "aiToolTsunami": "dữ liệu sóng thần", + "aiToolNotices": "thông báo thiên tai", + "aiToolPlaces": "địa điểm", + "aiToolTimeline": "thời điểm dữ liệu", + "aiToolOther": "dữ liệu", + "aiToolRunning": "Đang tra {subject}…", + "aiToolDone": "Đã tra {subject}", + "aiToolFailed": "Không tra được {subject}", + "aiErrorUnauthorized": "Khóa API không hợp lệ, đã hết hạn hoặc không có quyền dùng mô hình này.", + "aiErrorNotFound": "Không tìm thấy API hoặc mô hình. Hãy kiểm tra URL và tên mô hình.", + "aiErrorRateLimited": "Đã chạm giới hạn tốc độ hoặc hạn mức của dịch vụ AI. Hãy thử lại sau hoặc kiểm tra gói của bạn.", + "aiErrorRejected": "Dịch vụ AI đã từ chối yêu cầu. Mô hình có thể không hỗ trợ gọi công cụ.", + "aiErrorServer": "Dịch vụ AI tạm thời gặp lỗi. Hãy thử lại sau.", + "aiErrorNetwork": "Không kết nối được dịch vụ AI. Hãy kiểm tra mạng.", + "aiErrorTimeout": "Dịch vụ AI phản hồi quá lâu.", + "aiErrorBadResponse": "Không đọc được phản hồi của dịch vụ AI. URL này có thể không phải API tương thích OpenAI.", + "aiErrorToolsUnavailable": "Không kết nối được máy chủ dữ liệu DPIP. Hãy thử lại sau.", + "aiErrorTooManySteps": "AI đã tra dữ liệu quá nhiều lần mà chưa có câu trả lời. Hãy hỏi cụ thể hơn.", + "aiErrorServiceSaid": "Thông báo từ dịch vụ: {detail}" } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index a6abdaf87..9e04ad704 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "套用緊…", "onboardingNotifyTitle": "通知偏好", "onboardingNotifyBody": "揀一個起點先,之後隨時可以喺「通知設定」逐項調整。每個選項都會保留地震速報。", - "onboardingNotifySkip": "跳過,遲啲先設定" + "onboardingNotifySkip": "跳過,遲啲先設定", + "aiChatTitle": "AI 助理", + "aiChatNew": "新對話", + "aiChatAbout": "說明", + "aiChatInputHint": "問下地震、天氣或者颱風…", + "aiChatSend": "傳送", + "aiChatStop": "停止", + "aiChatDisclaimer": "AI 有機會出錯,緊急時請以官方警報為準。", + "aiChatEmptyTitle": "想知啲乜?", + "aiChatSuggestionQuakes": "最近有冇地震?", + "aiChatSuggestionRain": "我呢度今日會唔會落雨?", + "aiChatSuggestionTyphoon": "而家有冇颱風?", + "aiChatSuggestionAlerts": "而家有咩災害警報?", + "aiChatThinking": "諗緊…", + "aiChatWaiting": "等緊回覆…", + "aiChatStopped": "已停止", + "aiChatOpenSettings": "開啟設定", + "aiChatCopied": "已複製", + "aiChatSetupTitle": "未設定 AI 服務", + "aiChatSetupBody": "填入你自己嘅 AI 服務網址、金鑰同模型就可以開始。", + "aiChatSetupAction": "去設定", + "aiIntroTitle": "AI 助理", + "aiIntroBody": "問下最近嘅地震、聽日嘅天氣或者颱風動向。AI 會先即時查 DPIP 嘅資料,再用你嘅語言答你。", + "aiIntroOwnKeyTitle": "用你自己嘅 AI 服務", + "aiIntroOwnKeyBody": "填入任何兼容 OpenAI 格式、支援工具呼叫嘅 AI 服務網址同金鑰,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量同費用由你嘅 AI 服務計。", + "aiIntroLocalTitle": "金鑰只會存喺呢部手機,對話唔會儲存", + "aiIntroLocalBody": "API 金鑰存喺系統嘅金鑰庫(iOS 鑰匙圈、Android Keystore),唔會上載去 ExpTech,亦唔會跟備份還原去第二部手機。對話唔會儲存,閂咗 App 就會消失。", + "aiIntroDataTitle": "資料點樣傳送", + "aiIntroDataBody": "你嘅問題會由手機直接傳去你設定嘅 AI 服務,唔經 ExpTech。AI 要資料嗰陣,App 會向 DPIP 嘅公開資料伺服器查,只會傳查詢條件(例如地區代碼或者日期),唔包金鑰同對話。為咗知道「我呢度」係邊度,你喺 App 入面嘅所在地同收藏嘅鄉鎮(只有名稱同代碼)會一齊話俾 AI 知。", + "aiIntroCautionTitle": "AI 有機會出錯", + "aiIntroCautionBody": "AI 嘅答案只供參考。有機會有危險嗰陣,請先保護自己,並以中央氣象署同政府嘅官方警報同指示為準。", + "aiIntroMcpTitle": "自己整", + "aiIntroMcpBody": "DPIP 嘅資料亦都以 MCP 伺服器形式開放,唯讀、唔使金鑰。可以駁去 Claude、ChatGPT 或者其他支援 MCP 嘅工具,整你自己嘅應用。", + "aiIntroStart": "開始設定", + "aiMcpTitle": "DPIP MCP 伺服器", + "aiMcpBody": "支援 Streamable HTTP 嘅 MCP 用戶端都可以直接用呢個網址。", + "aiMcpCopyUrl": "複製網址", + "aiMcpCopyConfig": "複製設定範例", + "aiMcpCopied": "已複製", + "aiSettingsTitle": "AI 設定", + "aiSettingsService": "AI 服務", + "aiSettingsServiceBody": "任何兼容 OpenAI 格式(/chat/completions)同支援工具呼叫嘅服務都得。㩒下面嘅服務會自動填入網址。", + "aiSettingsBaseUrl": "API 網址", + "aiSettingsBaseUrlHelper": "API 嘅根網址,通常以 /v1 結尾。", + "aiSettingsUrlEmpty": "請輸入網址", + "aiSettingsUrlInvalid": "呢個唔係有效嘅網址", + "aiSettingsUrlInsecure": "請用 https://。只有駁去呢部手機本身(localhost)先可以用 http://。", + "aiSettingsApiKey": "API 金鑰", + "aiSettingsApiKeyHelper": "只會存喺呢部手機嘅系統金鑰庫。唔使金鑰嘅服務可以留空。", + "aiSettingsApiKeySaved": "已經儲存咗金鑰。留空會保留原本嘅金鑰。", + "aiSettingsShowKey": "顯示金鑰", + "aiSettingsHideKey": "隱藏金鑰", + "aiSettingsRemoveKey": "刪除已儲存嘅金鑰", + "aiSettingsKeyRemoved": "已刪除金鑰", + "aiSettingsModel": "模型", + "aiSettingsModelHelper": "要支援工具呼叫(function calling)嘅模型。㩒右邊個掣可以攞服務嘅模型清單。", + "aiSettingsModelEmpty": "請輸入或者揀模型", + "aiSettingsFetchModels": "攞模型清單", + "aiSettingsFilterModels": "搵模型", + "aiSettingsNoModels": "呢個服務冇列出任何模型,請直接輸入名稱。", + "aiSettingsSave": "儲存", + "aiSettingsSaved": "已儲存", + "aiSettingsSaveFailed": "儲存唔到金鑰,請再試一次。", + "aiSettingsPrivacy": "私隱", + "aiSettingsPrivacyBody": "金鑰只會存喺呢部手機嘅系統金鑰庫,唔會上載去 ExpTech。對話只會喺手機同你嘅 AI 服務之間傳送,唔會儲存。AI 查資料嗰陣,只有查詢條件會傳去 DPIP 嘅資料伺服器。", + "aiSettingsForget": "清除所有 AI 設定", + "aiSettingsForgetTitle": "清除 AI 設定?", + "aiSettingsForgetBody": "會由呢部手機刪除網址、模型同金鑰。", + "aiSettingsForgetConfirm": "清除", + "aiSettingsForgotten": "已清除 AI 設定", + "aiToolEarthquake": "地震資料", + "aiToolWeather": "天氣資料", + "aiToolTyphoon": "颱風資料", + "aiToolTsunami": "海嘯資料", + "aiToolNotices": "災害通知", + "aiToolPlaces": "地點", + "aiToolTimeline": "資料時間", + "aiToolOther": "資料", + "aiToolRunning": "查緊{subject}…", + "aiToolDone": "查咗{subject}", + "aiToolFailed": "查唔到{subject}", + "aiErrorUnauthorized": "API 金鑰無效、過咗期,或者冇用呢個模型嘅權限。", + "aiErrorNotFound": "搵唔到 API 或者模型,請檢查網址同模型名稱。", + "aiErrorRateLimited": "已經到咗 AI 服務嘅速率限制或者額度上限,請遲啲再試或者檢查計劃。", + "aiErrorRejected": "AI 服務拒絕咗呢個請求,揀咗嘅模型可能唔支援工具呼叫。", + "aiErrorServer": "AI 服務暫時出錯,請遲啲再試。", + "aiErrorNetwork": "連唔到 AI 服務,請檢查網絡。", + "aiErrorTimeout": "AI 服務太耐冇回應。", + "aiErrorBadResponse": "認唔到 AI 服務嘅回應,呢個網址可能唔係兼容 OpenAI 格式嘅 API。", + "aiErrorToolsUnavailable": "暫時連唔到 DPIP 資料伺服器,請遲啲再試。", + "aiErrorTooManySteps": "AI 查咗太多次資料都未有結論,請問得具體啲。", + "aiErrorServiceSaid": "服務嘅訊息:{detail}" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d8cda19a3..4e1f07928 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2109,5 +2109,96 @@ "notifyPresetApplying": "正在套用…", "onboardingNotifyTitle": "通知偏好", "onboardingNotifyBody": "先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。", - "onboardingNotifySkip": "略過,之後再設定" + "onboardingNotifySkip": "略過,之後再設定", + "aiChatTitle": "AI 助理", + "aiChatNew": "新對話", + "aiChatAbout": "說明", + "aiChatInputHint": "問問地震、天氣或颱風…", + "aiChatSend": "傳送", + "aiChatStop": "停止", + "aiChatDisclaimer": "AI 可能出錯,緊急時請以官方警報為準。", + "aiChatEmptyTitle": "想知道什麼?", + "aiChatSuggestionQuakes": "最近有地震嗎?", + "aiChatSuggestionRain": "我這裡今天會下雨嗎?", + "aiChatSuggestionTyphoon": "現在有颱風嗎?", + "aiChatSuggestionAlerts": "目前有哪些災害警報?", + "aiChatThinking": "思考中…", + "aiChatWaiting": "等待回應…", + "aiChatStopped": "已停止", + "aiChatOpenSettings": "開啟設定", + "aiChatCopied": "已複製", + "aiChatSetupTitle": "還沒設定 AI 服務", + "aiChatSetupBody": "填入你自己的 AI 服務網址、金鑰和模型就可以開始。", + "aiChatSetupAction": "前往設定", + "aiIntroTitle": "AI 助理", + "aiIntroBody": "問問最近的地震、明天的天氣或颱風動態。AI 會先即時查詢 DPIP 的資料,再用你的語言回答。", + "aiIntroOwnKeyTitle": "使用你自己的 AI 服務", + "aiIntroOwnKeyBody": "填入任何相容 OpenAI 格式、支援工具呼叫的 AI 服務網址和金鑰,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量和費用由你的 AI 服務計算。", + "aiIntroLocalTitle": "金鑰只存在這支手機,對話不會儲存", + "aiIntroLocalBody": "API 金鑰存在系統的金鑰庫(iOS 鑰匙圈、Android Keystore),不會上傳到 ExpTech,也不會隨備份還原到其他手機。對話不會被儲存,關掉 App 就會消失。", + "aiIntroDataTitle": "資料怎麼傳送", + "aiIntroDataBody": "你的問題會從手機直接送到你設定的 AI 服務,不經過 ExpTech。AI 需要資料時,App 會向 DPIP 的公開資料伺服器查詢,只送出查詢條件(例如地區代碼或日期),不含金鑰和對話。為了知道「我這裡」是哪裡,你在 App 裡的所在地和收藏的鄉鎮(只有名稱和代碼)會一起告訴 AI。", + "aiIntroCautionTitle": "AI 可能出錯", + "aiIntroCautionBody": "AI 的回答僅供參考。可能有危險時,請先保護自己,並以中央氣象署和政府的官方警報與指示為準。", + "aiIntroMcpTitle": "自己動手做", + "aiIntroMcpBody": "DPIP 的資料也開放成 MCP 伺服器,唯讀、不需要金鑰。可以接到 Claude、ChatGPT 或其他支援 MCP 的工具,做出你自己的應用。", + "aiIntroStart": "開始設定", + "aiMcpTitle": "DPIP MCP 伺服器", + "aiMcpBody": "支援 Streamable HTTP 的 MCP 用戶端都可以直接使用這個網址。", + "aiMcpCopyUrl": "複製網址", + "aiMcpCopyConfig": "複製設定範例", + "aiMcpCopied": "已複製", + "aiSettingsTitle": "AI 設定", + "aiSettingsService": "AI 服務", + "aiSettingsServiceBody": "任何相容 OpenAI 格式(/chat/completions)並支援工具呼叫的服務都可以。點選下方的服務會自動填入網址。", + "aiSettingsBaseUrl": "API 網址", + "aiSettingsBaseUrlHelper": "API 的根網址,通常以 /v1 結尾。", + "aiSettingsUrlEmpty": "請輸入網址", + "aiSettingsUrlInvalid": "這不是有效的網址", + "aiSettingsUrlInsecure": "請使用 https://。只有連到這支手機本身(localhost)才可以用 http://。", + "aiSettingsApiKey": "API 金鑰", + "aiSettingsApiKeyHelper": "只存在這支手機的系統金鑰庫。不需要金鑰的服務可以留空。", + "aiSettingsApiKeySaved": "已儲存金鑰。留空會保留原本的金鑰。", + "aiSettingsShowKey": "顯示金鑰", + "aiSettingsHideKey": "隱藏金鑰", + "aiSettingsRemoveKey": "刪除已儲存的金鑰", + "aiSettingsKeyRemoved": "已刪除金鑰", + "aiSettingsModel": "模型", + "aiSettingsModelHelper": "需要支援工具呼叫(function calling)的模型。點右邊的按鈕可以取得服務的模型清單。", + "aiSettingsModelEmpty": "請輸入或選擇模型", + "aiSettingsFetchModels": "取得模型清單", + "aiSettingsFilterModels": "搜尋模型", + "aiSettingsNoModels": "這個服務沒有列出任何模型,請直接輸入名稱。", + "aiSettingsSave": "儲存", + "aiSettingsSaved": "已儲存", + "aiSettingsSaveFailed": "無法儲存金鑰,請再試一次。", + "aiSettingsPrivacy": "隱私", + "aiSettingsPrivacyBody": "金鑰只存在這支手機的系統金鑰庫,不會上傳到 ExpTech。對話只在手機和你的 AI 服務之間傳送,不會被儲存。AI 查資料時,只有查詢條件會送到 DPIP 的資料伺服器。", + "aiSettingsForget": "清除所有 AI 設定", + "aiSettingsForgetTitle": "清除 AI 設定?", + "aiSettingsForgetBody": "會從這支手機刪除網址、模型和金鑰。", + "aiSettingsForgetConfirm": "清除", + "aiSettingsForgotten": "已清除 AI 設定", + "aiToolEarthquake": "地震資料", + "aiToolWeather": "天氣資料", + "aiToolTyphoon": "颱風資料", + "aiToolTsunami": "海嘯資料", + "aiToolNotices": "災害通知", + "aiToolPlaces": "地點", + "aiToolTimeline": "資料時間", + "aiToolOther": "資料", + "aiToolRunning": "正在查詢{subject}…", + "aiToolDone": "已查詢{subject}", + "aiToolFailed": "{subject}查詢失敗", + "aiErrorUnauthorized": "API 金鑰無效、過期,或沒有使用這個模型的權限。", + "aiErrorNotFound": "找不到 API 或模型,請檢查網址和模型名稱。", + "aiErrorRateLimited": "已達到 AI 服務的速率限制或額度上限,請稍後再試或檢查方案。", + "aiErrorRejected": "AI 服務拒絕了這個請求,選擇的模型可能不支援工具呼叫。", + "aiErrorServer": "AI 服務暫時出錯,請稍後再試。", + "aiErrorNetwork": "無法連上 AI 服務,請檢查網路。", + "aiErrorTimeout": "AI 服務太久沒有回應。", + "aiErrorBadResponse": "無法辨識 AI 服務的回應,這個網址可能不是相容 OpenAI 格式的 API。", + "aiErrorToolsUnavailable": "暫時無法連上 DPIP 資料伺服器,請稍後再試。", + "aiErrorTooManySteps": "AI 查了太多次資料還沒有結論,請把問題問得更具體。", + "aiErrorServiceSaid": "服務的訊息:{detail}" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 5df33f9d1..be9fae168 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "正在应用…", "onboardingNotifyTitle": "通知偏好", "onboardingNotifyBody": "先选一个起点,之后随时可以在“通知设置”中逐项调整。每个选项都会保留地震预警。", - "onboardingNotifySkip": "跳过,稍后再设置" + "onboardingNotifySkip": "跳过,稍后再设置", + "aiChatTitle": "AI 助手", + "aiChatNew": "新对话", + "aiChatAbout": "说明", + "aiChatInputHint": "问问地震、天气或台风…", + "aiChatSend": "发送", + "aiChatStop": "停止", + "aiChatDisclaimer": "AI 可能出错,紧急时请以官方警报为准。", + "aiChatEmptyTitle": "想了解什么?", + "aiChatSuggestionQuakes": "最近有地震吗?", + "aiChatSuggestionRain": "我这里今天会下雨吗?", + "aiChatSuggestionTyphoon": "现在有台风吗?", + "aiChatSuggestionAlerts": "目前有哪些灾害警报?", + "aiChatThinking": "思考中…", + "aiChatWaiting": "等待回复…", + "aiChatStopped": "已停止", + "aiChatOpenSettings": "打开设置", + "aiChatCopied": "已复制", + "aiChatSetupTitle": "尚未设置 AI 服务", + "aiChatSetupBody": "填入你自己的 AI 服务网址、密钥和模型即可开始。", + "aiChatSetupAction": "前往设置", + "aiIntroTitle": "AI 助手", + "aiIntroBody": "问问最近的地震、明天的天气或台风动态。AI 会先实时查询 DPIP 的数据,再用你的语言回答。", + "aiIntroOwnKeyTitle": "使用你自己的 AI 服务", + "aiIntroOwnKeyBody": "填入任何兼容 OpenAI 格式、支持工具调用的 AI 服务网址和密钥,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量和费用由你的 AI 服务计算。", + "aiIntroLocalTitle": "密钥只保存在这部手机上,对话不会保存", + "aiIntroLocalBody": "API 密钥保存在系统的密钥库(iOS 钥匙串、Android Keystore),不会上传到 ExpTech,也不会随备份恢复到其他手机。对话不会被保存,关闭 App 后就会消失。", + "aiIntroDataTitle": "数据如何传送", + "aiIntroDataBody": "你的问题会从手机直接发送到你设置的 AI 服务,不经过 ExpTech。AI 需要数据时,App 会向 DPIP 的公开数据服务器查询,只发送查询条件(例如地区代码或日期),不包含密钥和对话。为了知道“我这里”是哪里,你在 App 里的所在地和收藏的乡镇(只有名称和代码)会一并告诉 AI。", + "aiIntroCautionTitle": "AI 可能出错", + "aiIntroCautionBody": "AI 的回答仅供参考。可能有危险时,请先保护自己,并以中央气象署和政府发布的官方警报与指示为准。", + "aiIntroMcpTitle": "自己动手做", + "aiIntroMcpBody": "DPIP 的数据也以 MCP 服务器形式开放,只读、无需密钥。可以接入 Claude、ChatGPT 或其他支持 MCP 的工具,做出你自己的应用。", + "aiIntroStart": "开始设置", + "aiMcpTitle": "DPIP MCP 服务器", + "aiMcpBody": "支持 Streamable HTTP 的 MCP 客户端都可以直接使用这个网址。", + "aiMcpCopyUrl": "复制网址", + "aiMcpCopyConfig": "复制配置示例", + "aiMcpCopied": "已复制", + "aiSettingsTitle": "AI 设置", + "aiSettingsService": "AI 服务", + "aiSettingsServiceBody": "任何兼容 OpenAI 格式(/chat/completions)并支持工具调用的服务都可以。点按下方的服务会自动填入网址。", + "aiSettingsBaseUrl": "API 网址", + "aiSettingsBaseUrlHelper": "API 的根网址,通常以 /v1 结尾。", + "aiSettingsUrlEmpty": "请输入网址", + "aiSettingsUrlInvalid": "这不是有效的网址", + "aiSettingsUrlInsecure": "请使用 https://。只有连接这部手机本身(localhost)时才能用 http://。", + "aiSettingsApiKey": "API 密钥", + "aiSettingsApiKeyHelper": "只保存在这部手机的系统密钥库。不需要密钥的服务可以留空。", + "aiSettingsApiKeySaved": "已保存密钥。留空则保留原来的密钥。", + "aiSettingsShowKey": "显示密钥", + "aiSettingsHideKey": "隐藏密钥", + "aiSettingsRemoveKey": "删除已保存的密钥", + "aiSettingsKeyRemoved": "已删除密钥", + "aiSettingsModel": "模型", + "aiSettingsModelHelper": "需要支持工具调用(function calling)的模型。点按右侧按钮可获取服务的模型列表。", + "aiSettingsModelEmpty": "请输入或选择模型", + "aiSettingsFetchModels": "获取模型列表", + "aiSettingsFilterModels": "搜索模型", + "aiSettingsNoModels": "这个服务没有列出任何模型,请直接输入名称。", + "aiSettingsSave": "保存", + "aiSettingsSaved": "已保存", + "aiSettingsSaveFailed": "无法保存密钥,请再试一次。", + "aiSettingsPrivacy": "隐私", + "aiSettingsPrivacyBody": "密钥只保存在这部手机的系统密钥库,不会上传到 ExpTech。对话只在手机和你的 AI 服务之间传送,不会被保存。AI 查询数据时,只有查询条件会发送到 DPIP 的数据服务器。", + "aiSettingsForget": "清除所有 AI 设置", + "aiSettingsForgetTitle": "清除 AI 设置?", + "aiSettingsForgetBody": "将从这部手机删除网址、模型和密钥。", + "aiSettingsForgetConfirm": "清除", + "aiSettingsForgotten": "已清除 AI 设置", + "aiToolEarthquake": "地震数据", + "aiToolWeather": "天气数据", + "aiToolTyphoon": "台风数据", + "aiToolTsunami": "海啸数据", + "aiToolNotices": "灾害通知", + "aiToolPlaces": "地点", + "aiToolTimeline": "数据时间", + "aiToolOther": "数据", + "aiToolRunning": "正在查询{subject}…", + "aiToolDone": "已查询{subject}", + "aiToolFailed": "{subject}查询失败", + "aiErrorUnauthorized": "API 密钥无效、已过期,或没有使用此模型的权限。", + "aiErrorNotFound": "找不到 API 或模型,请检查网址和模型名称。", + "aiErrorRateLimited": "已达到 AI 服务的速率限制或额度上限,请稍后再试或检查套餐。", + "aiErrorRejected": "AI 服务拒绝了这个请求,所选模型可能不支持工具调用。", + "aiErrorServer": "AI 服务暂时出错,请稍后再试。", + "aiErrorNetwork": "无法连接 AI 服务,请检查网络。", + "aiErrorTimeout": "AI 服务太久没有响应。", + "aiErrorBadResponse": "无法识别 AI 服务的响应,这个网址可能不是兼容 OpenAI 格式的 API。", + "aiErrorToolsUnavailable": "暂时无法连接 DPIP 数据服务器,请稍后再试。", + "aiErrorTooManySteps": "AI 查询了太多次数据仍没有结论,请把问题问得更具体。", + "aiErrorServiceSaid": "服务返回:{detail}" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 9f2ae0379..e72e55188 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "正在套用…", "onboardingNotifyTitle": "通知偏好", "onboardingNotifyBody": "先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。", - "onboardingNotifySkip": "略過,之後再設定" + "onboardingNotifySkip": "略過,之後再設定", + "aiChatTitle": "AI 助理", + "aiChatNew": "新對話", + "aiChatAbout": "說明", + "aiChatInputHint": "問問地震、天氣或颱風…", + "aiChatSend": "傳送", + "aiChatStop": "停止", + "aiChatDisclaimer": "AI 可能出錯,緊急時請以官方警報為準。", + "aiChatEmptyTitle": "想知道甚麼?", + "aiChatSuggestionQuakes": "最近有地震嗎?", + "aiChatSuggestionRain": "我這裡今天會下雨嗎?", + "aiChatSuggestionTyphoon": "現在有颱風嗎?", + "aiChatSuggestionAlerts": "目前有哪些災害警報?", + "aiChatThinking": "思考中…", + "aiChatWaiting": "等待回應…", + "aiChatStopped": "已停止", + "aiChatOpenSettings": "開啟設定", + "aiChatCopied": "已複製", + "aiChatSetupTitle": "仍未設定 AI 服務", + "aiChatSetupBody": "填入你自己的 AI 服務網址、金鑰和模型就可以開始。", + "aiChatSetupAction": "前往設定", + "aiIntroTitle": "AI 助理", + "aiIntroBody": "問問最近的地震、明天的天氣或颱風動態。AI 會先即時查詢 DPIP 的資料,再用你的語言回答。", + "aiIntroOwnKeyTitle": "使用你自己的 AI 服務", + "aiIntroOwnKeyBody": "填入任何兼容 OpenAI 格式、支援工具呼叫的 AI 服務網址和金鑰,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量和費用由你的 AI 服務計算。", + "aiIntroLocalTitle": "金鑰只會儲存在這部手機,對話不會儲存", + "aiIntroLocalBody": "API 金鑰儲存在系統的金鑰庫(iOS 鑰匙圈、Android Keystore),不會上載到 ExpTech,也不會隨備份還原到其他手機。對話不會被儲存,關閉 App 後就會消失。", + "aiIntroDataTitle": "資料怎樣傳送", + "aiIntroDataBody": "你的問題會從手機直接傳送到你設定的 AI 服務,不經過 ExpTech。AI 需要資料時,App 會向 DPIP 的公開資料伺服器查詢,只傳送查詢條件(例如地區代碼或日期),不包含金鑰和對話。為了知道「我這裡」是哪裡,你在 App 中的所在地和收藏的鄉鎮(只有名稱和代碼)會一併告訴 AI。", + "aiIntroCautionTitle": "AI 可能出錯", + "aiIntroCautionBody": "AI 的回答僅供參考。可能有危險時,請先保護自己,並以中央氣象署和政府的官方警報與指示為準。", + "aiIntroMcpTitle": "自己動手做", + "aiIntroMcpBody": "DPIP 的資料亦以 MCP 伺服器形式開放,唯讀、不需要金鑰。可以接駁到 Claude、ChatGPT 或其他支援 MCP 的工具,做出你自己的應用。", + "aiIntroStart": "開始設定", + "aiMcpTitle": "DPIP MCP 伺服器", + "aiMcpBody": "支援 Streamable HTTP 的 MCP 用戶端都可以直接使用這個網址。", + "aiMcpCopyUrl": "複製網址", + "aiMcpCopyConfig": "複製設定範例", + "aiMcpCopied": "已複製", + "aiSettingsTitle": "AI 設定", + "aiSettingsService": "AI 服務", + "aiSettingsServiceBody": "任何兼容 OpenAI 格式(/chat/completions)並支援工具呼叫的服務都可以。點選下方的服務會自動填入網址。", + "aiSettingsBaseUrl": "API 網址", + "aiSettingsBaseUrlHelper": "API 的根網址,通常以 /v1 結尾。", + "aiSettingsUrlEmpty": "請輸入網址", + "aiSettingsUrlInvalid": "這不是有效的網址", + "aiSettingsUrlInsecure": "請使用 https://。只有連接這部手機本身(localhost)時才可以用 http://。", + "aiSettingsApiKey": "API 金鑰", + "aiSettingsApiKeyHelper": "只會儲存在這部手機的系統金鑰庫。不需要金鑰的服務可以留空。", + "aiSettingsApiKeySaved": "已儲存金鑰。留空會保留原本的金鑰。", + "aiSettingsShowKey": "顯示金鑰", + "aiSettingsHideKey": "隱藏金鑰", + "aiSettingsRemoveKey": "刪除已儲存的金鑰", + "aiSettingsKeyRemoved": "已刪除金鑰", + "aiSettingsModel": "模型", + "aiSettingsModelHelper": "需要支援工具呼叫(function calling)的模型。點右邊的按鈕可以取得服務的模型清單。", + "aiSettingsModelEmpty": "請輸入或選擇模型", + "aiSettingsFetchModels": "取得模型清單", + "aiSettingsFilterModels": "搜尋模型", + "aiSettingsNoModels": "這個服務沒有列出任何模型,請直接輸入名稱。", + "aiSettingsSave": "儲存", + "aiSettingsSaved": "已儲存", + "aiSettingsSaveFailed": "無法儲存金鑰,請再試一次。", + "aiSettingsPrivacy": "私隱", + "aiSettingsPrivacyBody": "金鑰只會儲存在這部手機的系統金鑰庫,不會上載到 ExpTech。對話只在手機和你的 AI 服務之間傳送,不會被儲存。AI 查詢資料時,只有查詢條件會傳送到 DPIP 的資料伺服器。", + "aiSettingsForget": "清除所有 AI 設定", + "aiSettingsForgetTitle": "清除 AI 設定?", + "aiSettingsForgetBody": "會從這部手機刪除網址、模型和金鑰。", + "aiSettingsForgetConfirm": "清除", + "aiSettingsForgotten": "已清除 AI 設定", + "aiToolEarthquake": "地震資料", + "aiToolWeather": "天氣資料", + "aiToolTyphoon": "颱風資料", + "aiToolTsunami": "海嘯資料", + "aiToolNotices": "災害通知", + "aiToolPlaces": "地點", + "aiToolTimeline": "資料時間", + "aiToolOther": "資料", + "aiToolRunning": "正在查詢{subject}…", + "aiToolDone": "已查詢{subject}", + "aiToolFailed": "{subject}查詢失敗", + "aiErrorUnauthorized": "API 金鑰無效、過期,或沒有使用這個模型的權限。", + "aiErrorNotFound": "找不到 API 或模型,請檢查網址和模型名稱。", + "aiErrorRateLimited": "已達到 AI 服務的速率限制或額度上限,請稍後再試或檢查計劃。", + "aiErrorRejected": "AI 服務拒絕了這個請求,所選的模型可能不支援工具呼叫。", + "aiErrorServer": "AI 服務暫時出錯,請稍後再試。", + "aiErrorNetwork": "無法連接 AI 服務,請檢查網絡。", + "aiErrorTimeout": "AI 服務太久沒有回應。", + "aiErrorBadResponse": "無法辨識 AI 服務的回應,這個網址可能不是兼容 OpenAI 格式的 API。", + "aiErrorToolsUnavailable": "暫時無法連接 DPIP 資料伺服器,請稍後再試。", + "aiErrorTooManySteps": "AI 查詢了太多次資料仍未有結論,請把問題問得更具體。", + "aiErrorServiceSaid": "服務的訊息:{detail}" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 01d92a72b..25b23765d 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -2117,5 +2117,96 @@ "notifyPresetApplying": "正在套用…", "onboardingNotifyTitle": "通知偏好", "onboardingNotifyBody": "先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。", - "onboardingNotifySkip": "略過,之後再設定" + "onboardingNotifySkip": "略過,之後再設定", + "aiChatTitle": "AI 助理", + "aiChatNew": "新對話", + "aiChatAbout": "說明", + "aiChatInputHint": "問問地震、天氣或颱風…", + "aiChatSend": "傳送", + "aiChatStop": "停止", + "aiChatDisclaimer": "AI 可能出錯,緊急時請以官方警報為準。", + "aiChatEmptyTitle": "想知道什麼?", + "aiChatSuggestionQuakes": "最近有地震嗎?", + "aiChatSuggestionRain": "我這裡今天會下雨嗎?", + "aiChatSuggestionTyphoon": "現在有颱風嗎?", + "aiChatSuggestionAlerts": "目前有哪些災害警報?", + "aiChatThinking": "思考中…", + "aiChatWaiting": "等待回應…", + "aiChatStopped": "已停止", + "aiChatOpenSettings": "開啟設定", + "aiChatCopied": "已複製", + "aiChatSetupTitle": "還沒設定 AI 服務", + "aiChatSetupBody": "填入你自己的 AI 服務網址、金鑰和模型就可以開始。", + "aiChatSetupAction": "前往設定", + "aiIntroTitle": "AI 助理", + "aiIntroBody": "問問最近的地震、明天的天氣或颱風動態。AI 會先即時查詢 DPIP 的資料,再用你的語言回答。", + "aiIntroOwnKeyTitle": "使用你自己的 AI 服務", + "aiIntroOwnKeyBody": "填入任何相容 OpenAI 格式、支援工具呼叫的 AI 服務網址和金鑰,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量和費用由你的 AI 服務計算。", + "aiIntroLocalTitle": "金鑰只存在這支手機,對話不會儲存", + "aiIntroLocalBody": "API 金鑰存在系統的金鑰庫(iOS 鑰匙圈、Android Keystore),不會上傳到 ExpTech,也不會隨備份還原到其他手機。對話不會被儲存,關掉 App 就會消失。", + "aiIntroDataTitle": "資料怎麼傳送", + "aiIntroDataBody": "你的問題會從手機直接送到你設定的 AI 服務,不經過 ExpTech。AI 需要資料時,App 會向 DPIP 的公開資料伺服器查詢,只送出查詢條件(例如地區代碼或日期),不含金鑰和對話。為了知道「我這裡」是哪裡,你在 App 裡的所在地和收藏的鄉鎮(只有名稱和代碼)會一起告訴 AI。", + "aiIntroCautionTitle": "AI 可能出錯", + "aiIntroCautionBody": "AI 的回答僅供參考。可能有危險時,請先保護自己,並以中央氣象署和政府的官方警報與指示為準。", + "aiIntroMcpTitle": "自己動手做", + "aiIntroMcpBody": "DPIP 的資料也開放成 MCP 伺服器,唯讀、不需要金鑰。可以接到 Claude、ChatGPT 或其他支援 MCP 的工具,做出你自己的應用。", + "aiIntroStart": "開始設定", + "aiMcpTitle": "DPIP MCP 伺服器", + "aiMcpBody": "支援 Streamable HTTP 的 MCP 用戶端都可以直接使用這個網址。", + "aiMcpCopyUrl": "複製網址", + "aiMcpCopyConfig": "複製設定範例", + "aiMcpCopied": "已複製", + "aiSettingsTitle": "AI 設定", + "aiSettingsService": "AI 服務", + "aiSettingsServiceBody": "任何相容 OpenAI 格式(/chat/completions)並支援工具呼叫的服務都可以。點選下方的服務會自動填入網址。", + "aiSettingsBaseUrl": "API 網址", + "aiSettingsBaseUrlHelper": "API 的根網址,通常以 /v1 結尾。", + "aiSettingsUrlEmpty": "請輸入網址", + "aiSettingsUrlInvalid": "這不是有效的網址", + "aiSettingsUrlInsecure": "請使用 https://。只有連到這支手機本身(localhost)才可以用 http://。", + "aiSettingsApiKey": "API 金鑰", + "aiSettingsApiKeyHelper": "只存在這支手機的系統金鑰庫。不需要金鑰的服務可以留空。", + "aiSettingsApiKeySaved": "已儲存金鑰。留空會保留原本的金鑰。", + "aiSettingsShowKey": "顯示金鑰", + "aiSettingsHideKey": "隱藏金鑰", + "aiSettingsRemoveKey": "刪除已儲存的金鑰", + "aiSettingsKeyRemoved": "已刪除金鑰", + "aiSettingsModel": "模型", + "aiSettingsModelHelper": "需要支援工具呼叫(function calling)的模型。點右邊的按鈕可以取得服務的模型清單。", + "aiSettingsModelEmpty": "請輸入或選擇模型", + "aiSettingsFetchModels": "取得模型清單", + "aiSettingsFilterModels": "搜尋模型", + "aiSettingsNoModels": "這個服務沒有列出任何模型,請直接輸入名稱。", + "aiSettingsSave": "儲存", + "aiSettingsSaved": "已儲存", + "aiSettingsSaveFailed": "無法儲存金鑰,請再試一次。", + "aiSettingsPrivacy": "隱私", + "aiSettingsPrivacyBody": "金鑰只存在這支手機的系統金鑰庫,不會上傳到 ExpTech。對話只在手機和你的 AI 服務之間傳送,不會被儲存。AI 查資料時,只有查詢條件會送到 DPIP 的資料伺服器。", + "aiSettingsForget": "清除所有 AI 設定", + "aiSettingsForgetTitle": "清除 AI 設定?", + "aiSettingsForgetBody": "會從這支手機刪除網址、模型和金鑰。", + "aiSettingsForgetConfirm": "清除", + "aiSettingsForgotten": "已清除 AI 設定", + "aiToolEarthquake": "地震資料", + "aiToolWeather": "天氣資料", + "aiToolTyphoon": "颱風資料", + "aiToolTsunami": "海嘯資料", + "aiToolNotices": "災害通知", + "aiToolPlaces": "地點", + "aiToolTimeline": "資料時間", + "aiToolOther": "資料", + "aiToolRunning": "正在查詢{subject}…", + "aiToolDone": "已查詢{subject}", + "aiToolFailed": "{subject}查詢失敗", + "aiErrorUnauthorized": "API 金鑰無效、過期,或沒有使用這個模型的權限。", + "aiErrorNotFound": "找不到 API 或模型,請檢查網址和模型名稱。", + "aiErrorRateLimited": "已達到 AI 服務的速率限制或額度上限,請稍後再試或檢查方案。", + "aiErrorRejected": "AI 服務拒絕了這個請求,選擇的模型可能不支援工具呼叫。", + "aiErrorServer": "AI 服務暫時出錯,請稍後再試。", + "aiErrorNetwork": "無法連上 AI 服務,請檢查網路。", + "aiErrorTimeout": "AI 服務太久沒有回應。", + "aiErrorBadResponse": "無法辨識 AI 服務的回應,這個網址可能不是相容 OpenAI 格式的 API。", + "aiErrorToolsUnavailable": "暫時無法連上 DPIP 資料伺服器,請稍後再試。", + "aiErrorTooManySteps": "AI 查了太多次資料還沒有結論,請把問題問得更具體。", + "aiErrorServiceSaid": "服務的訊息:{detail}" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 9b63f7f12..a8d84152e 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6809,6 +6809,552 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Skip for now'** String get onboardingNotifySkip; + + /// App bar title of the AI chat + /// + /// In en, this message translates to: + /// **'AI Assistant'** + String get aiChatTitle; + + /// Tooltip: start a new, empty conversation + /// + /// In en, this message translates to: + /// **'New conversation'** + String get aiChatNew; + + /// Tooltip: show the AI chat explanation again + /// + /// In en, this message translates to: + /// **'About the AI assistant'** + String get aiChatAbout; + + /// Hint in the empty question field + /// + /// In en, this message translates to: + /// **'Ask about earthquakes, weather or typhoons…'** + String get aiChatInputHint; + + /// Tooltip of the send button + /// + /// In en, this message translates to: + /// **'Send'** + String get aiChatSend; + + /// Tooltip of the button that stops an answer in progress + /// + /// In en, this message translates to: + /// **'Stop'** + String get aiChatStop; + + /// Always-visible line under the question field + /// + /// In en, this message translates to: + /// **'AI can be wrong. In an emergency, follow official alerts.'** + String get aiChatDisclaimer; + + /// Heading of an empty conversation, above suggested questions + /// + /// In en, this message translates to: + /// **'What would you like to know?'** + String get aiChatEmptyTitle; + + /// Suggested question; tapping it asks it + /// + /// In en, this message translates to: + /// **'Any earthquakes recently?'** + String get aiChatSuggestionQuakes; + + /// Suggested question about the user's own area + /// + /// In en, this message translates to: + /// **'Will it rain here today?'** + String get aiChatSuggestionRain; + + /// Suggested question + /// + /// In en, this message translates to: + /// **'Is there a typhoon right now?'** + String get aiChatSuggestionTyphoon; + + /// Suggested question + /// + /// In en, this message translates to: + /// **'What disaster alerts are in effect?'** + String get aiChatSuggestionAlerts; + + /// Shown while a reasoning model thinks before answering + /// + /// In en, this message translates to: + /// **'Thinking…'** + String get aiChatThinking; + + /// Shown until the AI service sends its first word + /// + /// In en, this message translates to: + /// **'Waiting for a reply…'** + String get aiChatWaiting; + + /// Under an answer the user stopped + /// + /// In en, this message translates to: + /// **'Stopped'** + String get aiChatStopped; + + /// Button under an error that is fixed in the AI settings + /// + /// In en, this message translates to: + /// **'Open settings'** + String get aiChatOpenSettings; + + /// Toast after a message is copied + /// + /// In en, this message translates to: + /// **'Copied'** + String get aiChatCopied; + + /// Title shown when no AI service is set up yet + /// + /// In en, this message translates to: + /// **'No AI service set up yet'** + String get aiChatSetupTitle; + + /// Body shown when no AI service is set up yet + /// + /// In en, this message translates to: + /// **'Enter your own AI service\'s URL, key and model to start.'** + String get aiChatSetupBody; + + /// Button that opens the AI settings + /// + /// In en, this message translates to: + /// **'Set up'** + String get aiChatSetupAction; + + /// Heading of the AI chat explanation page + /// + /// In en, this message translates to: + /// **'AI Assistant'** + String get aiIntroTitle; + + /// Opening paragraph of the AI chat explanation + /// + /// In en, this message translates to: + /// **'Ask about recent earthquakes, tomorrow\'s weather or a typhoon\'s track. The AI looks the data up in DPIP first, then answers in your language.'** + String get aiIntroBody; + + /// Explanation point title + /// + /// In en, this message translates to: + /// **'Use your own AI service'** + String get aiIntroOwnKeyTitle; + + /// Explanation point body; service names are proper nouns + /// + /// In en, this message translates to: + /// **'Enter the URL and key of any OpenAI-compatible AI service that supports tool calls, such as OpenAI, Google Gemini, Anthropic or OpenRouter. Usage and charges are counted by your AI service.'** + String get aiIntroOwnKeyBody; + + /// Explanation point title + /// + /// In en, this message translates to: + /// **'Your key stays on this phone; conversations are not saved'** + String get aiIntroLocalTitle; + + /// Explanation point body: where the key is stored, and that conversations are not saved + /// + /// In en, this message translates to: + /// **'The API key is kept in the system\'s key storage (iOS Keychain, Android Keystore). It is never uploaded to ExpTech and is not carried to another phone by a backup. Conversations are not saved: they disappear when the app is closed.'** + String get aiIntroLocalBody; + + /// Explanation point title + /// + /// In en, this message translates to: + /// **'Where your data goes'** + String get aiIntroDataTitle; + + /// Explanation point body: what is sent where. Townships are sent as names and codes only, never coordinates + /// + /// In en, this message translates to: + /// **'Your questions go straight from this phone to the AI service you set up, not through ExpTech. When the AI needs data, the app asks DPIP\'s public data server, sending only the query (such as an area code or a date) — never your key or the conversation. So the AI knows where \"here\" is, it is told your current township and saved townships in the app (names and codes only).'** + String get aiIntroDataBody; + + /// Explanation point title + /// + /// In en, this message translates to: + /// **'The AI can be wrong'** + String get aiIntroCautionTitle; + + /// Explanation point body: the AI is not an official warning source + /// + /// In en, this message translates to: + /// **'Its answers are for reference only. If you may be in danger, protect yourself first and follow official alerts and instructions from the Central Weather Administration and the government.'** + String get aiIntroCautionBody; + + /// Explanation point title: using the DPIP MCP server in other tools + /// + /// In en, this message translates to: + /// **'Build your own'** + String get aiIntroMcpTitle; + + /// Explanation point body; Claude and ChatGPT are product names + /// + /// In en, this message translates to: + /// **'DPIP\'s data is also open as an MCP server — read-only, no key needed. Connect it to Claude, ChatGPT or any other MCP-capable tool and build something of your own.'** + String get aiIntroMcpBody; + + /// Button that leaves the explanation and opens the AI settings + /// + /// In en, this message translates to: + /// **'Get started'** + String get aiIntroStart; + + /// Title of the card with the MCP server URL + /// + /// In en, this message translates to: + /// **'DPIP MCP server'** + String get aiMcpTitle; + + /// Card body; Streamable HTTP is the MCP transport name, not translated + /// + /// In en, this message translates to: + /// **'Any MCP client that supports Streamable HTTP can use this address directly.'** + String get aiMcpBody; + + /// Button: copy the MCP server URL + /// + /// In en, this message translates to: + /// **'Copy URL'** + String get aiMcpCopyUrl; + + /// Button: copy an example MCP client configuration + /// + /// In en, this message translates to: + /// **'Copy config example'** + String get aiMcpCopyConfig; + + /// Toast after copying the MCP URL or config + /// + /// In en, this message translates to: + /// **'Copied'** + String get aiMcpCopied; + + /// Title of the AI settings page + /// + /// In en, this message translates to: + /// **'AI settings'** + String get aiSettingsTitle; + + /// Section header in AI settings + /// + /// In en, this message translates to: + /// **'AI service'** + String get aiSettingsService; + + /// Text under the AI service header; /chat/completions is an API path + /// + /// In en, this message translates to: + /// **'Any OpenAI-compatible service (/chat/completions) with tool calls works. Tap a service below to fill in its URL.'** + String get aiSettingsServiceBody; + + /// Label of the API URL field + /// + /// In en, this message translates to: + /// **'API URL'** + String get aiSettingsBaseUrl; + + /// Helper under the API URL field + /// + /// In en, this message translates to: + /// **'The API\'s base URL, usually ending in /v1.'** + String get aiSettingsBaseUrlHelper; + + /// Error: the URL field is empty + /// + /// In en, this message translates to: + /// **'Enter a URL'** + String get aiSettingsUrlEmpty; + + /// Error: the URL is not a URL + /// + /// In en, this message translates to: + /// **'This is not a valid URL'** + String get aiSettingsUrlInvalid; + + /// Error: plain http to another host; https:// and http:// are literal + /// + /// In en, this message translates to: + /// **'Use https://. Plain http:// is only allowed to this phone itself (localhost).'** + String get aiSettingsUrlInsecure; + + /// Label of the API key field + /// + /// In en, this message translates to: + /// **'API key'** + String get aiSettingsApiKey; + + /// Helper under the API key field when no key is saved + /// + /// In en, this message translates to: + /// **'Stored only in this phone\'s key storage. Leave empty for a service that needs no key.'** + String get aiSettingsApiKeyHelper; + + /// Helper under the API key field when a key is saved + /// + /// In en, this message translates to: + /// **'A key is saved. Leave empty to keep it.'** + String get aiSettingsApiKeySaved; + + /// Tooltip: reveal the typed key + /// + /// In en, this message translates to: + /// **'Show key'** + String get aiSettingsShowKey; + + /// Tooltip: hide the typed key + /// + /// In en, this message translates to: + /// **'Hide key'** + String get aiSettingsHideKey; + + /// Button: delete the saved key + /// + /// In en, this message translates to: + /// **'Delete the saved key'** + String get aiSettingsRemoveKey; + + /// Toast after the saved key is deleted + /// + /// In en, this message translates to: + /// **'Key deleted'** + String get aiSettingsKeyRemoved; + + /// Label of the model field + /// + /// In en, this message translates to: + /// **'Model'** + String get aiSettingsModel; + + /// Helper under the model field + /// + /// In en, this message translates to: + /// **'Must support tool calls (function calling). The button on the right lists the service\'s models.'** + String get aiSettingsModelHelper; + + /// Error: no model given + /// + /// In en, this message translates to: + /// **'Enter or pick a model'** + String get aiSettingsModelEmpty; + + /// Tooltip: fetch the service's model list + /// + /// In en, this message translates to: + /// **'List models'** + String get aiSettingsFetchModels; + + /// Hint of the search field in the model list + /// + /// In en, this message translates to: + /// **'Filter models'** + String get aiSettingsFilterModels; + + /// Error: the service returned an empty model list + /// + /// In en, this message translates to: + /// **'This service lists no models. Type the model name instead.'** + String get aiSettingsNoModels; + + /// Button: save the AI settings + /// + /// In en, this message translates to: + /// **'Save'** + String get aiSettingsSave; + + /// Toast after saving + /// + /// In en, this message translates to: + /// **'Saved'** + String get aiSettingsSaved; + + /// Toast: the platform's key storage refused the key + /// + /// In en, this message translates to: + /// **'The key could not be saved. Please try again.'** + String get aiSettingsSaveFailed; + + /// Section header in AI settings + /// + /// In en, this message translates to: + /// **'Privacy'** + String get aiSettingsPrivacy; + + /// Short privacy summary in AI settings + /// + /// In en, this message translates to: + /// **'The key stays in this phone\'s key storage and is never uploaded to ExpTech. Conversations travel only between this phone and your AI service, and are not saved. When the AI looks data up, only the query goes to DPIP\'s data server.'** + String get aiSettingsPrivacyBody; + + /// Button: delete URL, model and key + /// + /// In en, this message translates to: + /// **'Clear all AI settings'** + String get aiSettingsForget; + + /// Confirmation dialog title + /// + /// In en, this message translates to: + /// **'Clear AI settings?'** + String get aiSettingsForgetTitle; + + /// Confirmation dialog body + /// + /// In en, this message translates to: + /// **'The URL, model and key will be deleted from this phone.'** + String get aiSettingsForgetBody; + + /// Confirmation dialog: the destructive action + /// + /// In en, this message translates to: + /// **'Clear'** + String get aiSettingsForgetConfirm; + + /// Toast after clearing the AI settings + /// + /// In en, this message translates to: + /// **'AI settings cleared'** + String get aiSettingsForgotten; + + /// Kind of data a tool looks up; inserted into the "looking up" lines + /// + /// In en, this message translates to: + /// **'earthquake data'** + String get aiToolEarthquake; + + /// Kind of data a tool looks up + /// + /// In en, this message translates to: + /// **'weather data'** + String get aiToolWeather; + + /// Kind of data a tool looks up + /// + /// In en, this message translates to: + /// **'typhoon data'** + String get aiToolTyphoon; + + /// Kind of data a tool looks up + /// + /// In en, this message translates to: + /// **'tsunami data'** + String get aiToolTsunami; + + /// Kind of data a tool looks up + /// + /// In en, this message translates to: + /// **'disaster notices'** + String get aiToolNotices; + + /// Kind of data a tool looks up + /// + /// In en, this message translates to: + /// **'places'** + String get aiToolPlaces; + + /// Kind of data a tool looks up: which times have data + /// + /// In en, this message translates to: + /// **'data times'** + String get aiToolTimeline; + + /// Kind of data a tool looks up, when the tool is not one the app knows + /// + /// In en, this message translates to: + /// **'data'** + String get aiToolOther; + + /// A tool call in progress; {subject} is one of the aiTool* kinds + /// + /// In en, this message translates to: + /// **'Looking up {subject}…'** + String aiToolRunning(String subject); + + /// A finished tool call + /// + /// In en, this message translates to: + /// **'Looked up {subject}'** + String aiToolDone(String subject); + + /// A tool call that failed + /// + /// In en, this message translates to: + /// **'Couldn\'t look up {subject}'** + String aiToolFailed(String subject); + + /// AI service answered 401/403 + /// + /// In en, this message translates to: + /// **'The API key is invalid or expired, or has no access to this model.'** + String get aiErrorUnauthorized; + + /// AI service answered 404 + /// + /// In en, this message translates to: + /// **'The API or the model was not found. Check the URL and the model name.'** + String get aiErrorNotFound; + + /// AI service answered 429 + /// + /// In en, this message translates to: + /// **'Your AI service\'s rate limit or quota was reached. Try again later or check your plan.'** + String get aiErrorRateLimited; + + /// AI service refused the request with another 4xx + /// + /// In en, this message translates to: + /// **'The AI service refused the request. The model may not support tool calls.'** + String get aiErrorRejected; + + /// AI service answered 5xx + /// + /// In en, this message translates to: + /// **'The AI service had a problem. Try again later.'** + String get aiErrorServer; + + /// No connection to the AI service + /// + /// In en, this message translates to: + /// **'Couldn\'t reach the AI service. Check your connection.'** + String get aiErrorNetwork; + + /// AI service went quiet too long + /// + /// In en, this message translates to: + /// **'The AI service took too long to respond.'** + String get aiErrorTimeout; + + /// AI service answered with something that is not a chat completion + /// + /// In en, this message translates to: + /// **'The AI service\'s reply could not be read. This URL may not be an OpenAI-compatible API.'** + String get aiErrorBadResponse; + + /// The DPIP MCP server could not be reached + /// + /// In en, this message translates to: + /// **'Couldn\'t reach the DPIP data server. Try again later.'** + String get aiErrorToolsUnavailable; + + /// The model kept calling tools past the per-question limit + /// + /// In en, this message translates to: + /// **'The AI looked up data too many times without an answer. Try a more specific question.'** + String get aiErrorTooManySteps; + + /// The AI service's own error text, untranslated; {detail} is e.g. "HTTP 401 · Invalid API key" + /// + /// In en, this message translates to: + /// **'Service said: {detail}'** + String aiErrorServiceSaid(String detail); } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 0f81b86ce..cc407fc2a 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3635,4 +3635,312 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboardingNotifySkip => 'Skip for now'; + + @override + String get aiChatTitle => 'AI Assistant'; + + @override + String get aiChatNew => 'New conversation'; + + @override + String get aiChatAbout => 'About the AI assistant'; + + @override + String get aiChatInputHint => 'Ask about earthquakes, weather or typhoons…'; + + @override + String get aiChatSend => 'Send'; + + @override + String get aiChatStop => 'Stop'; + + @override + String get aiChatDisclaimer => + 'AI can be wrong. In an emergency, follow official alerts.'; + + @override + String get aiChatEmptyTitle => 'What would you like to know?'; + + @override + String get aiChatSuggestionQuakes => 'Any earthquakes recently?'; + + @override + String get aiChatSuggestionRain => 'Will it rain here today?'; + + @override + String get aiChatSuggestionTyphoon => 'Is there a typhoon right now?'; + + @override + String get aiChatSuggestionAlerts => 'What disaster alerts are in effect?'; + + @override + String get aiChatThinking => 'Thinking…'; + + @override + String get aiChatWaiting => 'Waiting for a reply…'; + + @override + String get aiChatStopped => 'Stopped'; + + @override + String get aiChatOpenSettings => 'Open settings'; + + @override + String get aiChatCopied => 'Copied'; + + @override + String get aiChatSetupTitle => 'No AI service set up yet'; + + @override + String get aiChatSetupBody => + 'Enter your own AI service\'s URL, key and model to start.'; + + @override + String get aiChatSetupAction => 'Set up'; + + @override + String get aiIntroTitle => 'AI Assistant'; + + @override + String get aiIntroBody => + 'Ask about recent earthquakes, tomorrow\'s weather or a typhoon\'s track. The AI looks the data up in DPIP first, then answers in your language.'; + + @override + String get aiIntroOwnKeyTitle => 'Use your own AI service'; + + @override + String get aiIntroOwnKeyBody => + 'Enter the URL and key of any OpenAI-compatible AI service that supports tool calls, such as OpenAI, Google Gemini, Anthropic or OpenRouter. Usage and charges are counted by your AI service.'; + + @override + String get aiIntroLocalTitle => + 'Your key stays on this phone; conversations are not saved'; + + @override + String get aiIntroLocalBody => + 'The API key is kept in the system\'s key storage (iOS Keychain, Android Keystore). It is never uploaded to ExpTech and is not carried to another phone by a backup. Conversations are not saved: they disappear when the app is closed.'; + + @override + String get aiIntroDataTitle => 'Where your data goes'; + + @override + String get aiIntroDataBody => + 'Your questions go straight from this phone to the AI service you set up, not through ExpTech. When the AI needs data, the app asks DPIP\'s public data server, sending only the query (such as an area code or a date) — never your key or the conversation. So the AI knows where \"here\" is, it is told your current township and saved townships in the app (names and codes only).'; + + @override + String get aiIntroCautionTitle => 'The AI can be wrong'; + + @override + String get aiIntroCautionBody => + 'Its answers are for reference only. If you may be in danger, protect yourself first and follow official alerts and instructions from the Central Weather Administration and the government.'; + + @override + String get aiIntroMcpTitle => 'Build your own'; + + @override + String get aiIntroMcpBody => + 'DPIP\'s data is also open as an MCP server — read-only, no key needed. Connect it to Claude, ChatGPT or any other MCP-capable tool and build something of your own.'; + + @override + String get aiIntroStart => 'Get started'; + + @override + String get aiMcpTitle => 'DPIP MCP server'; + + @override + String get aiMcpBody => + 'Any MCP client that supports Streamable HTTP can use this address directly.'; + + @override + String get aiMcpCopyUrl => 'Copy URL'; + + @override + String get aiMcpCopyConfig => 'Copy config example'; + + @override + String get aiMcpCopied => 'Copied'; + + @override + String get aiSettingsTitle => 'AI settings'; + + @override + String get aiSettingsService => 'AI service'; + + @override + String get aiSettingsServiceBody => + 'Any OpenAI-compatible service (/chat/completions) with tool calls works. Tap a service below to fill in its URL.'; + + @override + String get aiSettingsBaseUrl => 'API URL'; + + @override + String get aiSettingsBaseUrlHelper => + 'The API\'s base URL, usually ending in /v1.'; + + @override + String get aiSettingsUrlEmpty => 'Enter a URL'; + + @override + String get aiSettingsUrlInvalid => 'This is not a valid URL'; + + @override + String get aiSettingsUrlInsecure => + 'Use https://. Plain http:// is only allowed to this phone itself (localhost).'; + + @override + String get aiSettingsApiKey => 'API key'; + + @override + String get aiSettingsApiKeyHelper => + 'Stored only in this phone\'s key storage. Leave empty for a service that needs no key.'; + + @override + String get aiSettingsApiKeySaved => 'A key is saved. Leave empty to keep it.'; + + @override + String get aiSettingsShowKey => 'Show key'; + + @override + String get aiSettingsHideKey => 'Hide key'; + + @override + String get aiSettingsRemoveKey => 'Delete the saved key'; + + @override + String get aiSettingsKeyRemoved => 'Key deleted'; + + @override + String get aiSettingsModel => 'Model'; + + @override + String get aiSettingsModelHelper => + 'Must support tool calls (function calling). The button on the right lists the service\'s models.'; + + @override + String get aiSettingsModelEmpty => 'Enter or pick a model'; + + @override + String get aiSettingsFetchModels => 'List models'; + + @override + String get aiSettingsFilterModels => 'Filter models'; + + @override + String get aiSettingsNoModels => + 'This service lists no models. Type the model name instead.'; + + @override + String get aiSettingsSave => 'Save'; + + @override + String get aiSettingsSaved => 'Saved'; + + @override + String get aiSettingsSaveFailed => + 'The key could not be saved. Please try again.'; + + @override + String get aiSettingsPrivacy => 'Privacy'; + + @override + String get aiSettingsPrivacyBody => + 'The key stays in this phone\'s key storage and is never uploaded to ExpTech. Conversations travel only between this phone and your AI service, and are not saved. When the AI looks data up, only the query goes to DPIP\'s data server.'; + + @override + String get aiSettingsForget => 'Clear all AI settings'; + + @override + String get aiSettingsForgetTitle => 'Clear AI settings?'; + + @override + String get aiSettingsForgetBody => + 'The URL, model and key will be deleted from this phone.'; + + @override + String get aiSettingsForgetConfirm => 'Clear'; + + @override + String get aiSettingsForgotten => 'AI settings cleared'; + + @override + String get aiToolEarthquake => 'earthquake data'; + + @override + String get aiToolWeather => 'weather data'; + + @override + String get aiToolTyphoon => 'typhoon data'; + + @override + String get aiToolTsunami => 'tsunami data'; + + @override + String get aiToolNotices => 'disaster notices'; + + @override + String get aiToolPlaces => 'places'; + + @override + String get aiToolTimeline => 'data times'; + + @override + String get aiToolOther => 'data'; + + @override + String aiToolRunning(String subject) { + return 'Looking up $subject…'; + } + + @override + String aiToolDone(String subject) { + return 'Looked up $subject'; + } + + @override + String aiToolFailed(String subject) { + return 'Couldn\'t look up $subject'; + } + + @override + String get aiErrorUnauthorized => + 'The API key is invalid or expired, or has no access to this model.'; + + @override + String get aiErrorNotFound => + 'The API or the model was not found. Check the URL and the model name.'; + + @override + String get aiErrorRateLimited => + 'Your AI service\'s rate limit or quota was reached. Try again later or check your plan.'; + + @override + String get aiErrorRejected => + 'The AI service refused the request. The model may not support tool calls.'; + + @override + String get aiErrorServer => 'The AI service had a problem. Try again later.'; + + @override + String get aiErrorNetwork => + 'Couldn\'t reach the AI service. Check your connection.'; + + @override + String get aiErrorTimeout => 'The AI service took too long to respond.'; + + @override + String get aiErrorBadResponse => + 'The AI service\'s reply could not be read. This URL may not be an OpenAI-compatible API.'; + + @override + String get aiErrorToolsUnavailable => + 'Couldn\'t reach the DPIP data server. Try again later.'; + + @override + String get aiErrorTooManySteps => + 'The AI looked up data too many times without an answer. Try a more specific question.'; + + @override + String aiErrorServiceSaid(String detail) { + return 'Service said: $detail'; + } } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 199fb8250..3734a2008 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3658,4 +3658,316 @@ class AppLocalizationsFil extends AppLocalizations { @override String get onboardingNotifySkip => 'Laktawan muna'; + + @override + String get aiChatTitle => 'AI Assistant'; + + @override + String get aiChatNew => 'Bagong usapan'; + + @override + String get aiChatAbout => 'Tungkol dito'; + + @override + String get aiChatInputHint => 'Magtanong tungkol sa lindol, panahon o bagyo…'; + + @override + String get aiChatSend => 'Ipadala'; + + @override + String get aiChatStop => 'Itigil'; + + @override + String get aiChatDisclaimer => + 'Maaaring magkamali ang AI. Sa emergency, sundin ang opisyal na babala.'; + + @override + String get aiChatEmptyTitle => 'Ano ang gusto mong malaman?'; + + @override + String get aiChatSuggestionQuakes => 'May lindol ba kamakailan?'; + + @override + String get aiChatSuggestionRain => 'Uulan ba rito ngayong araw?'; + + @override + String get aiChatSuggestionTyphoon => 'May bagyo ba ngayon?'; + + @override + String get aiChatSuggestionAlerts => + 'Anong mga babala sa sakuna ang umiiral?'; + + @override + String get aiChatThinking => 'Nag-iisip…'; + + @override + String get aiChatWaiting => 'Hinihintay ang sagot…'; + + @override + String get aiChatStopped => 'Itinigil'; + + @override + String get aiChatOpenSettings => 'Buksan ang settings'; + + @override + String get aiChatCopied => 'Nakopya'; + + @override + String get aiChatSetupTitle => 'Hindi pa naka-set up ang AI service'; + + @override + String get aiChatSetupBody => + 'Ilagay ang URL, key at model ng sarili mong AI service para magsimula.'; + + @override + String get aiChatSetupAction => 'I-set up'; + + @override + String get aiIntroTitle => 'AI Assistant'; + + @override + String get aiIntroBody => + 'Magtanong tungkol sa mga kamakailang lindol, panahon bukas o daan ng bagyo. Hahanapin muna ng AI ang datos ng DPIP nang real-time, saka sasagot sa iyong wika.'; + + @override + String get aiIntroOwnKeyTitle => 'Gamitin ang sarili mong AI service'; + + @override + String get aiIntroOwnKeyBody => + 'Ilagay ang URL at key ng anumang AI service na compatible sa OpenAI at sumusuporta sa tool calls, gaya ng OpenAI, Google Gemini, Anthropic o OpenRouter. Ang paggamit at singil ay kinukuwenta ng iyong AI service.'; + + @override + String get aiIntroLocalTitle => + 'Sa teleponong ito lang ang key; hindi sine-save ang usapan'; + + @override + String get aiIntroLocalBody => + 'Nakatago ang API key sa key storage ng system (iOS Keychain, Android Keystore). Hindi ito ina-upload sa ExpTech at hindi nadadala sa ibang telepono sa pamamagitan ng backup. Hindi sine-save ang mga usapan: nawawala ang mga ito kapag isinara ang app.'; + + @override + String get aiIntroDataTitle => 'Saan napupunta ang datos'; + + @override + String get aiIntroDataBody => + 'Direktang ipinapadala ang mga tanong mo mula sa teleponong ito papunta sa AI service na itinakda mo, hindi dumadaan sa ExpTech. Kapag kailangan ng AI ng datos, nagtatanong ang app sa pampublikong data server ng DPIP at ipinapadala lang ang query (gaya ng area code o petsa), hindi ang key o ang usapan. Para malaman ng AI kung saan ang \"dito\", sinasabi rin sa kaniya ang kasalukuyang township at mga naka-save na township sa app (pangalan at code lang).'; + + @override + String get aiIntroCautionTitle => 'Maaaring magkamali ang AI'; + + @override + String get aiIntroCautionBody => + 'Para sa sanggunian lang ang mga sagot. Kapag maaaring may panganib, protektahan muna ang sarili at sundin ang opisyal na babala at tagubilin ng Central Weather Administration at ng pamahalaan.'; + + @override + String get aiIntroMcpTitle => 'Gumawa ng sarili mo'; + + @override + String get aiIntroMcpBody => + 'Bukas din ang datos ng DPIP bilang MCP server — read-only, walang key na kailangan. Ikonekta ito sa Claude, ChatGPT o anumang tool na may MCP at gumawa ng sarili mong app.'; + + @override + String get aiIntroStart => 'Simulan ang setup'; + + @override + String get aiMcpTitle => 'DPIP MCP server'; + + @override + String get aiMcpBody => + 'Magagamit agad ang address na ito ng anumang MCP client na sumusuporta sa Streamable HTTP.'; + + @override + String get aiMcpCopyUrl => 'Kopyahin ang URL'; + + @override + String get aiMcpCopyConfig => 'Kopyahin ang halimbawang config'; + + @override + String get aiMcpCopied => 'Nakopya'; + + @override + String get aiSettingsTitle => 'Settings ng AI'; + + @override + String get aiSettingsService => 'AI service'; + + @override + String get aiSettingsServiceBody => + 'Gumagana ang anumang serbisyong compatible sa OpenAI (/chat/completions) na may tool calls. I-tap ang isang serbisyo sa ibaba para ilagay ang URL nito.'; + + @override + String get aiSettingsBaseUrl => 'API URL'; + + @override + String get aiSettingsBaseUrlHelper => + 'Ang base URL ng API, karaniwang nagtatapos sa /v1.'; + + @override + String get aiSettingsUrlEmpty => 'Maglagay ng URL'; + + @override + String get aiSettingsUrlInvalid => 'Hindi ito wastong URL'; + + @override + String get aiSettingsUrlInsecure => + 'Gumamit ng https://. Pinapayagan lang ang http:// para sa teleponong ito mismo (localhost).'; + + @override + String get aiSettingsApiKey => 'API key'; + + @override + String get aiSettingsApiKeyHelper => + 'Sa key storage lang ng teleponong ito nakatago. Iwanang blangko kung walang key na kailangan ang serbisyo.'; + + @override + String get aiSettingsApiKeySaved => + 'May naka-save nang key. Iwanang blangko para panatilihin ito.'; + + @override + String get aiSettingsShowKey => 'Ipakita ang key'; + + @override + String get aiSettingsHideKey => 'Itago ang key'; + + @override + String get aiSettingsRemoveKey => 'Burahin ang naka-save na key'; + + @override + String get aiSettingsKeyRemoved => 'Nabura ang key'; + + @override + String get aiSettingsModel => 'Model'; + + @override + String get aiSettingsModelHelper => + 'Dapat sumusuporta sa tool calls (function calling). Ipinapakita ng button sa kanan ang mga model ng serbisyo.'; + + @override + String get aiSettingsModelEmpty => 'Maglagay o pumili ng model'; + + @override + String get aiSettingsFetchModels => 'Ilista ang mga model'; + + @override + String get aiSettingsFilterModels => 'Hanapin ang model'; + + @override + String get aiSettingsNoModels => + 'Walang inililistang model ang serbisyong ito. I-type na lang ang pangalan ng model.'; + + @override + String get aiSettingsSave => 'I-save'; + + @override + String get aiSettingsSaved => 'Na-save'; + + @override + String get aiSettingsSaveFailed => + 'Hindi na-save ang key. Pakisubukang muli.'; + + @override + String get aiSettingsPrivacy => 'Privacy'; + + @override + String get aiSettingsPrivacyBody => + 'Nasa key storage ng teleponong ito ang key at hindi ito ina-upload sa ExpTech. Sa pagitan lang ng teleponong ito at ng iyong AI service dumadaan ang usapan, at hindi ito sine-save. Kapag naghahanap ng datos ang AI, ang query lang ang ipinapadala sa data server ng DPIP.'; + + @override + String get aiSettingsForget => 'Burahin lahat ng settings ng AI'; + + @override + String get aiSettingsForgetTitle => 'Burahin ang settings ng AI?'; + + @override + String get aiSettingsForgetBody => + 'Buburahin sa teleponong ito ang URL, model at key.'; + + @override + String get aiSettingsForgetConfirm => 'Burahin'; + + @override + String get aiSettingsForgotten => 'Nabura ang settings ng AI'; + + @override + String get aiToolEarthquake => 'datos ng lindol'; + + @override + String get aiToolWeather => 'datos ng panahon'; + + @override + String get aiToolTyphoon => 'datos ng bagyo'; + + @override + String get aiToolTsunami => 'datos ng tsunami'; + + @override + String get aiToolNotices => 'abiso sa sakuna'; + + @override + String get aiToolPlaces => 'lugar'; + + @override + String get aiToolTimeline => 'oras ng datos'; + + @override + String get aiToolOther => 'datos'; + + @override + String aiToolRunning(String subject) { + return 'Hinahanap ang $subject…'; + } + + @override + String aiToolDone(String subject) { + return 'Nahanap ang $subject'; + } + + @override + String aiToolFailed(String subject) { + return 'Hindi mahanap ang $subject'; + } + + @override + String get aiErrorUnauthorized => + 'Hindi wasto o expired ang API key, o wala itong access sa model na ito.'; + + @override + String get aiErrorNotFound => + 'Hindi nahanap ang API o model. Suriin ang URL at pangalan ng model.'; + + @override + String get aiErrorRateLimited => + 'Naabot na ang rate limit o quota ng iyong AI service. Subukang muli mamaya o suriin ang iyong plan.'; + + @override + String get aiErrorRejected => + 'Tinanggihan ng AI service ang request. Maaaring hindi sumusuporta sa tool calls ang model.'; + + @override + String get aiErrorServer => + 'Nagkaproblema ang AI service. Subukang muli mamaya.'; + + @override + String get aiErrorNetwork => + 'Hindi maabot ang AI service. Suriin ang iyong koneksyon.'; + + @override + String get aiErrorTimeout => + 'Masyadong natagalan ang AI service sa pagsagot.'; + + @override + String get aiErrorBadResponse => + 'Hindi mabasa ang sagot ng AI service. Maaaring hindi API na compatible sa OpenAI ang URL na ito.'; + + @override + String get aiErrorToolsUnavailable => + 'Hindi maabot ang data server ng DPIP. Subukang muli mamaya.'; + + @override + String get aiErrorTooManySteps => + 'Masyadong maraming beses naghanap ng datos ang AI nang walang sagot. Subukan ang mas tiyak na tanong.'; + + @override + String aiErrorServiceSaid(String detail) { + return 'Sabi ng serbisyo: $detail'; + } } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 0c0977fe3..aa850ba38 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3649,4 +3649,312 @@ class AppLocalizationsId extends AppLocalizations { @override String get onboardingNotifySkip => 'Lewati dulu'; + + @override + String get aiChatTitle => 'Asisten AI'; + + @override + String get aiChatNew => 'Percakapan baru'; + + @override + String get aiChatAbout => 'Tentang'; + + @override + String get aiChatInputHint => 'Tanya soal gempa, cuaca, atau topan…'; + + @override + String get aiChatSend => 'Kirim'; + + @override + String get aiChatStop => 'Hentikan'; + + @override + String get aiChatDisclaimer => + 'AI bisa salah. Saat darurat, ikuti peringatan resmi.'; + + @override + String get aiChatEmptyTitle => 'Ingin tahu apa?'; + + @override + String get aiChatSuggestionQuakes => 'Ada gempa akhir-akhir ini?'; + + @override + String get aiChatSuggestionRain => 'Apakah di sini akan hujan hari ini?'; + + @override + String get aiChatSuggestionTyphoon => 'Apakah sekarang ada topan?'; + + @override + String get aiChatSuggestionAlerts => + 'Peringatan bencana apa yang sedang berlaku?'; + + @override + String get aiChatThinking => 'Sedang berpikir…'; + + @override + String get aiChatWaiting => 'Menunggu balasan…'; + + @override + String get aiChatStopped => 'Dihentikan'; + + @override + String get aiChatOpenSettings => 'Buka pengaturan'; + + @override + String get aiChatCopied => 'Disalin'; + + @override + String get aiChatSetupTitle => 'Layanan AI belum diatur'; + + @override + String get aiChatSetupBody => + 'Masukkan URL, kunci, dan model layanan AI Anda untuk mulai.'; + + @override + String get aiChatSetupAction => 'Atur'; + + @override + String get aiIntroTitle => 'Asisten AI'; + + @override + String get aiIntroBody => + 'Tanyakan gempa terbaru, cuaca besok, atau jalur topan. AI akan mencari data DPIP secara real-time lalu menjawab dalam bahasa Anda.'; + + @override + String get aiIntroOwnKeyTitle => 'Gunakan layanan AI Anda sendiri'; + + @override + String get aiIntroOwnKeyBody => + 'Masukkan URL dan kunci layanan AI yang kompatibel dengan OpenAI dan mendukung pemanggilan alat, seperti OpenAI, Google Gemini, Anthropic, atau OpenRouter. Pemakaian dan biaya dihitung oleh layanan AI Anda.'; + + @override + String get aiIntroLocalTitle => + 'Kunci hanya ada di ponsel ini; percakapan tidak disimpan'; + + @override + String get aiIntroLocalBody => + 'Kunci API disimpan di penyimpanan kunci sistem (iOS Keychain, Android Keystore), tidak pernah diunggah ke ExpTech, dan tidak ikut pindah ke ponsel lain lewat cadangan. Percakapan tidak disimpan dan hilang saat aplikasi ditutup.'; + + @override + String get aiIntroDataTitle => 'Ke mana data dikirim'; + + @override + String get aiIntroDataBody => + 'Pertanyaan Anda dikirim langsung dari ponsel ke layanan AI yang Anda atur, tidak melalui ExpTech. Saat AI butuh data, aplikasi menanyakan server data publik DPIP dan hanya mengirim kriteria pencarian (seperti kode wilayah atau tanggal), tanpa kunci atau percakapan. Agar AI tahu di mana \"di sini\", lokasi saat ini dan kota yang Anda simpan di aplikasi (hanya nama dan kode) juga diberitahukan ke AI.'; + + @override + String get aiIntroCautionTitle => 'AI bisa salah'; + + @override + String get aiIntroCautionBody => + 'Jawabannya hanya sebagai referensi. Jika mungkin ada bahaya, lindungi diri Anda terlebih dahulu dan ikuti peringatan serta instruksi resmi dari Central Weather Administration dan pemerintah.'; + + @override + String get aiIntroMcpTitle => 'Buat sendiri'; + + @override + String get aiIntroMcpBody => + 'Data DPIP juga terbuka sebagai server MCP — hanya baca, tanpa kunci. Hubungkan ke Claude, ChatGPT, atau alat lain yang mendukung MCP dan buat sesuatu milik Anda sendiri.'; + + @override + String get aiIntroStart => 'Mulai atur'; + + @override + String get aiMcpTitle => 'Server MCP DPIP'; + + @override + String get aiMcpBody => + 'Klien MCP apa pun yang mendukung Streamable HTTP dapat langsung memakai alamat ini.'; + + @override + String get aiMcpCopyUrl => 'Salin URL'; + + @override + String get aiMcpCopyConfig => 'Salin contoh konfigurasi'; + + @override + String get aiMcpCopied => 'Disalin'; + + @override + String get aiSettingsTitle => 'Pengaturan AI'; + + @override + String get aiSettingsService => 'Layanan AI'; + + @override + String get aiSettingsServiceBody => + 'Layanan apa pun yang kompatibel dengan OpenAI (/chat/completions) dan mendukung pemanggilan alat bisa dipakai. Ketuk layanan di bawah untuk mengisi URL-nya.'; + + @override + String get aiSettingsBaseUrl => 'URL API'; + + @override + String get aiSettingsBaseUrlHelper => 'URL dasar API, biasanya diakhiri /v1.'; + + @override + String get aiSettingsUrlEmpty => 'Masukkan URL'; + + @override + String get aiSettingsUrlInvalid => 'URL tidak valid'; + + @override + String get aiSettingsUrlInsecure => + 'Gunakan https://. http:// hanya diizinkan untuk ponsel ini sendiri (localhost).'; + + @override + String get aiSettingsApiKey => 'Kunci API'; + + @override + String get aiSettingsApiKeyHelper => + 'Hanya disimpan di penyimpanan kunci ponsel ini. Kosongkan jika layanan tidak memerlukan kunci.'; + + @override + String get aiSettingsApiKeySaved => + 'Kunci sudah tersimpan. Kosongkan untuk tetap memakainya.'; + + @override + String get aiSettingsShowKey => 'Tampilkan kunci'; + + @override + String get aiSettingsHideKey => 'Sembunyikan kunci'; + + @override + String get aiSettingsRemoveKey => 'Hapus kunci tersimpan'; + + @override + String get aiSettingsKeyRemoved => 'Kunci dihapus'; + + @override + String get aiSettingsModel => 'Model'; + + @override + String get aiSettingsModelHelper => + 'Harus mendukung pemanggilan alat (function calling). Tombol di kanan menampilkan daftar model layanan.'; + + @override + String get aiSettingsModelEmpty => 'Masukkan atau pilih model'; + + @override + String get aiSettingsFetchModels => 'Tampilkan daftar model'; + + @override + String get aiSettingsFilterModels => 'Cari model'; + + @override + String get aiSettingsNoModels => + 'Layanan ini tidak menampilkan model. Ketik nama modelnya langsung.'; + + @override + String get aiSettingsSave => 'Simpan'; + + @override + String get aiSettingsSaved => 'Tersimpan'; + + @override + String get aiSettingsSaveFailed => 'Kunci tidak dapat disimpan. Coba lagi.'; + + @override + String get aiSettingsPrivacy => 'Privasi'; + + @override + String get aiSettingsPrivacyBody => + 'Kunci tetap di penyimpanan kunci ponsel ini dan tidak pernah diunggah ke ExpTech. Percakapan hanya berlangsung antara ponsel ini dan layanan AI Anda, dan tidak disimpan. Saat AI mencari data, hanya kriteria pencarian yang dikirim ke server data DPIP.'; + + @override + String get aiSettingsForget => 'Hapus semua pengaturan AI'; + + @override + String get aiSettingsForgetTitle => 'Hapus pengaturan AI?'; + + @override + String get aiSettingsForgetBody => + 'URL, model, dan kunci akan dihapus dari ponsel ini.'; + + @override + String get aiSettingsForgetConfirm => 'Hapus'; + + @override + String get aiSettingsForgotten => 'Pengaturan AI dihapus'; + + @override + String get aiToolEarthquake => 'data gempa'; + + @override + String get aiToolWeather => 'data cuaca'; + + @override + String get aiToolTyphoon => 'data topan'; + + @override + String get aiToolTsunami => 'data tsunami'; + + @override + String get aiToolNotices => 'pemberitahuan bencana'; + + @override + String get aiToolPlaces => 'lokasi'; + + @override + String get aiToolTimeline => 'waktu data'; + + @override + String get aiToolOther => 'data'; + + @override + String aiToolRunning(String subject) { + return 'Mencari $subject…'; + } + + @override + String aiToolDone(String subject) { + return 'Selesai mencari $subject'; + } + + @override + String aiToolFailed(String subject) { + return 'Gagal mencari $subject'; + } + + @override + String get aiErrorUnauthorized => + 'Kunci API tidak valid atau kedaluwarsa, atau tidak punya akses ke model ini.'; + + @override + String get aiErrorNotFound => + 'API atau model tidak ditemukan. Periksa URL dan nama model.'; + + @override + String get aiErrorRateLimited => + 'Batas laju atau kuota layanan AI Anda tercapai. Coba lagi nanti atau periksa paket Anda.'; + + @override + String get aiErrorRejected => + 'Layanan AI menolak permintaan. Model mungkin tidak mendukung pemanggilan alat.'; + + @override + String get aiErrorServer => 'Layanan AI sedang bermasalah. Coba lagi nanti.'; + + @override + String get aiErrorNetwork => + 'Tidak dapat menjangkau layanan AI. Periksa koneksi Anda.'; + + @override + String get aiErrorTimeout => 'Layanan AI terlalu lama merespons.'; + + @override + String get aiErrorBadResponse => + 'Balasan layanan AI tidak dapat dibaca. URL ini mungkin bukan API yang kompatibel dengan OpenAI.'; + + @override + String get aiErrorToolsUnavailable => + 'Tidak dapat menjangkau server data DPIP. Coba lagi nanti.'; + + @override + String get aiErrorTooManySteps => + 'AI mencari data terlalu banyak kali tanpa jawaban. Coba pertanyaan yang lebih spesifik.'; + + @override + String aiErrorServiceSaid(String detail) { + return 'Pesan layanan: $detail'; + } } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 375fbb167..2d0a55344 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3565,4 +3565,301 @@ class AppLocalizationsJa extends AppLocalizations { @override String get onboardingNotifySkip => 'あとで設定する'; + + @override + String get aiChatTitle => 'AIアシスタント'; + + @override + String get aiChatNew => '新しい会話'; + + @override + String get aiChatAbout => '説明'; + + @override + String get aiChatInputHint => '地震・天気・台風について質問…'; + + @override + String get aiChatSend => '送信'; + + @override + String get aiChatStop => '停止'; + + @override + String get aiChatDisclaimer => 'AIは間違えることがあります。緊急時は公式の警報に従ってください。'; + + @override + String get aiChatEmptyTitle => '何を知りたいですか?'; + + @override + String get aiChatSuggestionQuakes => '最近地震はありましたか?'; + + @override + String get aiChatSuggestionRain => '今日ここは雨が降りますか?'; + + @override + String get aiChatSuggestionTyphoon => '今、台風はありますか?'; + + @override + String get aiChatSuggestionAlerts => '現在出ている災害情報は?'; + + @override + String get aiChatThinking => '考え中…'; + + @override + String get aiChatWaiting => '応答を待っています…'; + + @override + String get aiChatStopped => '停止しました'; + + @override + String get aiChatOpenSettings => '設定を開く'; + + @override + String get aiChatCopied => 'コピーしました'; + + @override + String get aiChatSetupTitle => 'AIサービスが未設定です'; + + @override + String get aiChatSetupBody => '自分のAIサービスのURL、APIキー、モデルを入力すると使えます。'; + + @override + String get aiChatSetupAction => '設定する'; + + @override + String get aiIntroTitle => 'AIアシスタント'; + + @override + String get aiIntroBody => + '最近の地震、明日の天気、台風の進路などを質問できます。AIはまずDPIPのデータをリアルタイムで調べてから、あなたの言語で答えます。'; + + @override + String get aiIntroOwnKeyTitle => '自分のAIサービスを使います'; + + @override + String get aiIntroOwnKeyBody => + 'ツール呼び出しに対応した OpenAI 互換の AI サービス(OpenAI、Google Gemini、Anthropic、OpenRouter など)の URL と API キーを入力します。利用量と料金はそのサービス側で計算されます。'; + + @override + String get aiIntroLocalTitle => 'キーはこの端末にだけ保存され、会話は保存されません'; + + @override + String get aiIntroLocalBody => + 'APIキーはシステムのキー保管庫(iOSのキーチェーン、AndroidのKeystore)に保存され、ExpTechにアップロードされることはなく、バックアップで別の端末に移ることもありません。会話は保存されず、アプリを閉じると消えます。'; + + @override + String get aiIntroDataTitle => 'データの流れ'; + + @override + String get aiIntroDataBody => + '質問はこの端末から、設定したAIサービスへ直接送られ、ExpTechを経由しません。AIがデータを必要とするときは、アプリがDPIPの公開データサーバーに問い合わせますが、送るのは検索条件(地域コードや日付など)だけで、キーや会話は含みません。「ここ」がどこかを伝えるため、アプリに登録した現在地と保存した郷鎮(名称とコードのみ)もAIに伝えます。'; + + @override + String get aiIntroCautionTitle => 'AIは間違えることがあります'; + + @override + String get aiIntroCautionBody => + '回答は参考情報です。危険がありそうなときは、まず身の安全を確保し、中央気象署や政府の公式の警報・指示に従ってください。'; + + @override + String get aiIntroMcpTitle => '自分で作る'; + + @override + String get aiIntroMcpBody => + 'DPIPのデータはMCPサーバーとしても公開されています(読み取り専用、キー不要)。Claude、ChatGPT など MCP 対応ツールにつないで、自分だけの使い方ができます。'; + + @override + String get aiIntroStart => '設定を始める'; + + @override + String get aiMcpTitle => 'DPIP MCPサーバー'; + + @override + String get aiMcpBody => 'Streamable HTTP に対応した MCP クライアントなら、このアドレスをそのまま使えます。'; + + @override + String get aiMcpCopyUrl => 'URLをコピー'; + + @override + String get aiMcpCopyConfig => '設定例をコピー'; + + @override + String get aiMcpCopied => 'コピーしました'; + + @override + String get aiSettingsTitle => 'AI設定'; + + @override + String get aiSettingsService => 'AIサービス'; + + @override + String get aiSettingsServiceBody => + 'ツール呼び出しに対応した OpenAI 互換サービス(/chat/completions)なら使えます。下のサービスをタップすると URL が入力されます。'; + + @override + String get aiSettingsBaseUrl => 'API の URL'; + + @override + String get aiSettingsBaseUrlHelper => 'APIのベースURL。通常は /v1 で終わります。'; + + @override + String get aiSettingsUrlEmpty => 'URLを入力してください'; + + @override + String get aiSettingsUrlInvalid => '有効なURLではありません'; + + @override + String get aiSettingsUrlInsecure => + 'https:// を使ってください。http:// はこの端末自身(localhost)にだけ使えます。'; + + @override + String get aiSettingsApiKey => 'APIキー'; + + @override + String get aiSettingsApiKeyHelper => + 'この端末のキー保管庫にのみ保存されます。キーが不要なサービスでは空欄のままにしてください。'; + + @override + String get aiSettingsApiKeySaved => 'キーは保存済みです。空欄のままにすると今のキーを使い続けます。'; + + @override + String get aiSettingsShowKey => 'キーを表示'; + + @override + String get aiSettingsHideKey => 'キーを隠す'; + + @override + String get aiSettingsRemoveKey => '保存したキーを削除'; + + @override + String get aiSettingsKeyRemoved => 'キーを削除しました'; + + @override + String get aiSettingsModel => 'モデル'; + + @override + String get aiSettingsModelHelper => + 'ツール呼び出し(function calling)に対応したモデルが必要です。右のボタンでモデル一覧を取得できます。'; + + @override + String get aiSettingsModelEmpty => 'モデルを入力または選択してください'; + + @override + String get aiSettingsFetchModels => 'モデル一覧を取得'; + + @override + String get aiSettingsFilterModels => 'モデルを検索'; + + @override + String get aiSettingsNoModels => 'このサービスはモデルを公開していません。モデル名を直接入力してください。'; + + @override + String get aiSettingsSave => '保存'; + + @override + String get aiSettingsSaved => '保存しました'; + + @override + String get aiSettingsSaveFailed => 'キーを保存できませんでした。もう一度お試しください。'; + + @override + String get aiSettingsPrivacy => 'プライバシー'; + + @override + String get aiSettingsPrivacyBody => + 'キーはこの端末のキー保管庫にのみ保存され、ExpTechにアップロードされることはありません。会話はこの端末とあなたのAIサービスの間だけでやり取りされ、保存されません。AIがデータを調べるときは、検索条件だけがDPIPのデータサーバーに送られます。'; + + @override + String get aiSettingsForget => 'AI設定をすべて消去'; + + @override + String get aiSettingsForgetTitle => 'AI設定を消去しますか?'; + + @override + String get aiSettingsForgetBody => 'URL、モデル、キーがこの端末から削除されます。'; + + @override + String get aiSettingsForgetConfirm => '消去'; + + @override + String get aiSettingsForgotten => 'AI設定を消去しました'; + + @override + String get aiToolEarthquake => '地震情報'; + + @override + String get aiToolWeather => '気象情報'; + + @override + String get aiToolTyphoon => '台風情報'; + + @override + String get aiToolTsunami => '津波情報'; + + @override + String get aiToolNotices => '災害情報'; + + @override + String get aiToolPlaces => '地点'; + + @override + String get aiToolTimeline => 'データの時刻'; + + @override + String get aiToolOther => 'データ'; + + @override + String aiToolRunning(String subject) { + return '$subjectを調べています…'; + } + + @override + String aiToolDone(String subject) { + return '$subjectを調べました'; + } + + @override + String aiToolFailed(String subject) { + return '$subjectを取得できませんでした'; + } + + @override + String get aiErrorUnauthorized => 'APIキーが無効か期限切れ、またはこのモデルを使う権限がありません。'; + + @override + String get aiErrorNotFound => 'APIまたはモデルが見つかりません。URLとモデル名を確認してください。'; + + @override + String get aiErrorRateLimited => + 'AIサービスのレート制限または利用枠の上限に達しました。しばらくしてから再試行するか、プランを確認してください。'; + + @override + String get aiErrorRejected => + 'AIサービスがリクエストを拒否しました。モデルがツール呼び出しに対応していない可能性があります。'; + + @override + String get aiErrorServer => 'AIサービスで一時的なエラーが発生しました。しばらくしてから再試行してください。'; + + @override + String get aiErrorNetwork => 'AIサービスに接続できません。ネットワークを確認してください。'; + + @override + String get aiErrorTimeout => 'AIサービスの応答に時間がかかりすぎています。'; + + @override + String get aiErrorBadResponse => + 'AIサービスの応答を読み取れませんでした。このURLはOpenAI互換のAPIではない可能性があります。'; + + @override + String get aiErrorToolsUnavailable => + 'DPIPのデータサーバーに接続できません。しばらくしてから再試行してください。'; + + @override + String get aiErrorTooManySteps => + 'AIがデータを何度も調べましたが答えが出ませんでした。もっと具体的に質問してください。'; + + @override + String aiErrorServiceSaid(String detail) { + return 'サービスからのメッセージ:$detail'; + } } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 97221f89a..29cf509fb 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3567,4 +3567,302 @@ class AppLocalizationsKo extends AppLocalizations { @override String get onboardingNotifySkip => '나중에 설정'; + + @override + String get aiChatTitle => 'AI 어시스턴트'; + + @override + String get aiChatNew => '새 대화'; + + @override + String get aiChatAbout => '안내'; + + @override + String get aiChatInputHint => '지진, 날씨, 태풍에 대해 물어보세요…'; + + @override + String get aiChatSend => '보내기'; + + @override + String get aiChatStop => '중지'; + + @override + String get aiChatDisclaimer => 'AI는 틀릴 수 있습니다. 긴급 시에는 공식 경보를 따르세요.'; + + @override + String get aiChatEmptyTitle => '무엇이 궁금하세요?'; + + @override + String get aiChatSuggestionQuakes => '최근에 지진이 있었나요?'; + + @override + String get aiChatSuggestionRain => '오늘 여기 비가 올까요?'; + + @override + String get aiChatSuggestionTyphoon => '지금 태풍이 있나요?'; + + @override + String get aiChatSuggestionAlerts => '현재 발효 중인 재난 경보는?'; + + @override + String get aiChatThinking => '생각 중…'; + + @override + String get aiChatWaiting => '응답을 기다리는 중…'; + + @override + String get aiChatStopped => '중지됨'; + + @override + String get aiChatOpenSettings => '설정 열기'; + + @override + String get aiChatCopied => '복사했습니다'; + + @override + String get aiChatSetupTitle => 'AI 서비스가 설정되지 않았습니다'; + + @override + String get aiChatSetupBody => '사용 중인 AI 서비스의 URL, 키, 모델을 입력하면 시작할 수 있습니다.'; + + @override + String get aiChatSetupAction => '설정하기'; + + @override + String get aiIntroTitle => 'AI 어시스턴트'; + + @override + String get aiIntroBody => + '최근 지진, 내일 날씨, 태풍 진로 등을 물어보세요. AI가 먼저 DPIP 데이터를 실시간으로 조회한 뒤 사용자의 언어로 답합니다.'; + + @override + String get aiIntroOwnKeyTitle => '자신의 AI 서비스를 사용합니다'; + + @override + String get aiIntroOwnKeyBody => + '도구 호출을 지원하는 OpenAI 호환 AI 서비스(OpenAI, Google Gemini, Anthropic, OpenRouter 등)의 URL과 키를 입력하세요. 사용량과 요금은 해당 AI 서비스에서 계산됩니다.'; + + @override + String get aiIntroLocalTitle => '키는 이 기기에만 저장되고, 대화는 저장되지 않습니다'; + + @override + String get aiIntroLocalBody => + 'API 키는 시스템 키 저장소(iOS 키체인, Android Keystore)에 보관되며 ExpTech에 업로드되지 않고, 백업을 통해 다른 기기로 옮겨지지도 않습니다. 대화는 저장되지 않으며 앱을 닫으면 사라집니다.'; + + @override + String get aiIntroDataTitle => '데이터 전송 방식'; + + @override + String get aiIntroDataBody => + '질문은 이 기기에서 설정한 AI 서비스로 바로 전송되며 ExpTech를 거치지 않습니다. AI에 데이터가 필요하면 앱이 DPIP 공개 데이터 서버에 조회하며, 조회 조건(지역 코드나 날짜 등)만 보내고 키나 대화 내용은 보내지 않습니다. ‘여기’가 어디인지 알 수 있도록 앱에 설정된 현재 위치와 저장한 향진(이름과 코드만)을 AI에 알려 줍니다.'; + + @override + String get aiIntroCautionTitle => 'AI는 틀릴 수 있습니다'; + + @override + String get aiIntroCautionBody => + '답변은 참고용입니다. 위험할 수 있을 때는 먼저 자신을 보호하고, 중앙기상서와 정부의 공식 경보와 지시를 따르세요.'; + + @override + String get aiIntroMcpTitle => '직접 만들어 보기'; + + @override + String get aiIntroMcpBody => + 'DPIP 데이터는 MCP 서버로도 공개되어 있습니다(읽기 전용, 키 불필요). Claude, ChatGPT 등 MCP를 지원하는 도구에 연결해 나만의 활용법을 만들어 보세요.'; + + @override + String get aiIntroStart => '설정 시작'; + + @override + String get aiMcpTitle => 'DPIP MCP 서버'; + + @override + String get aiMcpBody => + 'Streamable HTTP를 지원하는 MCP 클라이언트라면 이 주소를 바로 사용할 수 있습니다.'; + + @override + String get aiMcpCopyUrl => 'URL 복사'; + + @override + String get aiMcpCopyConfig => '설정 예시 복사'; + + @override + String get aiMcpCopied => '복사했습니다'; + + @override + String get aiSettingsTitle => 'AI 설정'; + + @override + String get aiSettingsService => 'AI 서비스'; + + @override + String get aiSettingsServiceBody => + '도구 호출을 지원하는 OpenAI 호환 서비스(/chat/completions)라면 사용할 수 있습니다. 아래 서비스를 누르면 URL이 입력됩니다.'; + + @override + String get aiSettingsBaseUrl => 'API URL'; + + @override + String get aiSettingsBaseUrlHelper => 'API의 기본 URL로, 보통 /v1로 끝납니다.'; + + @override + String get aiSettingsUrlEmpty => 'URL을 입력하세요'; + + @override + String get aiSettingsUrlInvalid => '올바른 URL이 아닙니다'; + + @override + String get aiSettingsUrlInsecure => + 'https://를 사용하세요. http://는 이 기기 자체(localhost)에만 사용할 수 있습니다.'; + + @override + String get aiSettingsApiKey => 'API 키'; + + @override + String get aiSettingsApiKeyHelper => + '이 기기의 키 저장소에만 저장됩니다. 키가 필요 없는 서비스라면 비워 두세요.'; + + @override + String get aiSettingsApiKeySaved => '키가 저장되어 있습니다. 비워 두면 기존 키를 유지합니다.'; + + @override + String get aiSettingsShowKey => '키 표시'; + + @override + String get aiSettingsHideKey => '키 숨기기'; + + @override + String get aiSettingsRemoveKey => '저장된 키 삭제'; + + @override + String get aiSettingsKeyRemoved => '키를 삭제했습니다'; + + @override + String get aiSettingsModel => '모델'; + + @override + String get aiSettingsModelHelper => + '도구 호출(function calling)을 지원하는 모델이어야 합니다. 오른쪽 버튼으로 모델 목록을 불러올 수 있습니다.'; + + @override + String get aiSettingsModelEmpty => '모델을 입력하거나 선택하세요'; + + @override + String get aiSettingsFetchModels => '모델 목록 가져오기'; + + @override + String get aiSettingsFilterModels => '모델 검색'; + + @override + String get aiSettingsNoModels => '이 서비스는 모델 목록을 제공하지 않습니다. 모델 이름을 직접 입력하세요.'; + + @override + String get aiSettingsSave => '저장'; + + @override + String get aiSettingsSaved => '저장했습니다'; + + @override + String get aiSettingsSaveFailed => '키를 저장하지 못했습니다. 다시 시도하세요.'; + + @override + String get aiSettingsPrivacy => '개인정보'; + + @override + String get aiSettingsPrivacyBody => + '키는 이 기기의 키 저장소에만 보관되며 ExpTech에 업로드되지 않습니다. 대화는 이 기기와 AI 서비스 사이에서만 오가며 저장되지 않습니다. AI가 데이터를 조회할 때는 조회 조건만 DPIP 데이터 서버로 전송됩니다.'; + + @override + String get aiSettingsForget => 'AI 설정 모두 지우기'; + + @override + String get aiSettingsForgetTitle => 'AI 설정을 지울까요?'; + + @override + String get aiSettingsForgetBody => '이 기기에서 URL, 모델, 키가 삭제됩니다.'; + + @override + String get aiSettingsForgetConfirm => '지우기'; + + @override + String get aiSettingsForgotten => 'AI 설정을 지웠습니다'; + + @override + String get aiToolEarthquake => '지진 정보'; + + @override + String get aiToolWeather => '날씨 정보'; + + @override + String get aiToolTyphoon => '태풍 정보'; + + @override + String get aiToolTsunami => '쓰나미 정보'; + + @override + String get aiToolNotices => '재난 알림'; + + @override + String get aiToolPlaces => '지역'; + + @override + String get aiToolTimeline => '데이터 시각'; + + @override + String get aiToolOther => '데이터'; + + @override + String aiToolRunning(String subject) { + return '$subject 조회 중…'; + } + + @override + String aiToolDone(String subject) { + return '$subject 조회 완료'; + } + + @override + String aiToolFailed(String subject) { + return '$subject 조회 실패'; + } + + @override + String get aiErrorUnauthorized => 'API 키가 잘못되었거나 만료되었거나, 이 모델을 사용할 권한이 없습니다.'; + + @override + String get aiErrorNotFound => 'API 또는 모델을 찾을 수 없습니다. URL과 모델 이름을 확인하세요.'; + + @override + String get aiErrorRateLimited => + 'AI 서비스의 요청 한도나 사용량 한도에 도달했습니다. 잠시 후 다시 시도하거나 요금제를 확인하세요.'; + + @override + String get aiErrorRejected => + 'AI 서비스가 요청을 거부했습니다. 선택한 모델이 도구 호출을 지원하지 않을 수 있습니다.'; + + @override + String get aiErrorServer => 'AI 서비스에 일시적인 문제가 발생했습니다. 잠시 후 다시 시도하세요.'; + + @override + String get aiErrorNetwork => 'AI 서비스에 연결할 수 없습니다. 네트워크를 확인하세요.'; + + @override + String get aiErrorTimeout => 'AI 서비스의 응답이 너무 오래 걸립니다.'; + + @override + String get aiErrorBadResponse => + 'AI 서비스의 응답을 읽을 수 없습니다. 이 URL은 OpenAI 호환 API가 아닐 수 있습니다.'; + + @override + String get aiErrorToolsUnavailable => + 'DPIP 데이터 서버에 연결할 수 없습니다. 잠시 후 다시 시도하세요.'; + + @override + String get aiErrorTooManySteps => + 'AI가 데이터를 너무 여러 번 조회했지만 답을 내지 못했습니다. 더 구체적으로 질문해 보세요.'; + + @override + String aiErrorServiceSaid(String detail) { + return '서비스 메시지: $detail'; + } } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 9b68d9135..ecae4f217 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3626,4 +3626,313 @@ class AppLocalizationsTh extends AppLocalizations { @override String get onboardingNotifySkip => 'ข้ามไปก่อน'; + + @override + String get aiChatTitle => 'ผู้ช่วย AI'; + + @override + String get aiChatNew => 'บทสนทนาใหม่'; + + @override + String get aiChatAbout => 'เกี่ยวกับ'; + + @override + String get aiChatInputHint => + 'ถามเรื่องแผ่นดินไหว สภาพอากาศ หรือพายุไต้ฝุ่น…'; + + @override + String get aiChatSend => 'ส่ง'; + + @override + String get aiChatStop => 'หยุด'; + + @override + String get aiChatDisclaimer => + 'AI อาจผิดพลาดได้ ในกรณีฉุกเฉินให้ยึดตามประกาศเตือนภัยอย่างเป็นทางการ'; + + @override + String get aiChatEmptyTitle => 'อยากรู้อะไร?'; + + @override + String get aiChatSuggestionQuakes => 'ช่วงนี้มีแผ่นดินไหวไหม?'; + + @override + String get aiChatSuggestionRain => 'วันนี้ที่นี่ฝนจะตกไหม?'; + + @override + String get aiChatSuggestionTyphoon => 'ตอนนี้มีพายุไต้ฝุ่นไหม?'; + + @override + String get aiChatSuggestionAlerts => 'ตอนนี้มีประกาศเตือนภัยอะไรบ้าง?'; + + @override + String get aiChatThinking => 'กำลังคิด…'; + + @override + String get aiChatWaiting => 'กำลังรอคำตอบ…'; + + @override + String get aiChatStopped => 'หยุดแล้ว'; + + @override + String get aiChatOpenSettings => 'เปิดการตั้งค่า'; + + @override + String get aiChatCopied => 'คัดลอกแล้ว'; + + @override + String get aiChatSetupTitle => 'ยังไม่ได้ตั้งค่าบริการ AI'; + + @override + String get aiChatSetupBody => + 'กรอก URL คีย์ และโมเดลของบริการ AI ของคุณเพื่อเริ่มใช้งาน'; + + @override + String get aiChatSetupAction => 'ไปที่การตั้งค่า'; + + @override + String get aiIntroTitle => 'ผู้ช่วย AI'; + + @override + String get aiIntroBody => + 'ถามเรื่องแผ่นดินไหวล่าสุด สภาพอากาศพรุ่งนี้ หรือเส้นทางพายุไต้ฝุ่นได้ AI จะค้นข้อมูลจาก DPIP แบบเรียลไทม์ก่อน แล้วตอบเป็นภาษาของคุณ'; + + @override + String get aiIntroOwnKeyTitle => 'ใช้บริการ AI ของคุณเอง'; + + @override + String get aiIntroOwnKeyBody => + 'กรอก URL และคีย์ของบริการ AI ที่รองรับรูปแบบ OpenAI และการเรียกใช้เครื่องมือ เช่น OpenAI, Google Gemini, Anthropic หรือ OpenRouter ปริมาณการใช้และค่าใช้จ่ายคิดโดยบริการ AI ของคุณ'; + + @override + String get aiIntroLocalTitle => + 'คีย์อยู่ในโทรศัพท์เครื่องนี้เท่านั้น และบทสนทนาจะไม่ถูกบันทึก'; + + @override + String get aiIntroLocalBody => + 'คีย์ API ถูกเก็บในที่เก็บคีย์ของระบบ (iOS Keychain, Android Keystore) ไม่ถูกอัปโหลดไปยัง ExpTech และจะไม่ถูกย้ายไปยังโทรศัพท์เครื่องอื่นผ่านการสำรองข้อมูล บทสนทนาจะไม่ถูกบันทึกและจะหายไปเมื่อปิดแอป'; + + @override + String get aiIntroDataTitle => 'ข้อมูลถูกส่งไปที่ไหน'; + + @override + String get aiIntroDataBody => + 'คำถามของคุณถูกส่งจากโทรศัพท์ไปยังบริการ AI ที่คุณตั้งค่าไว้โดยตรง ไม่ผ่าน ExpTech เมื่อ AI ต้องการข้อมูล แอปจะสอบถามเซิร์ฟเวอร์ข้อมูลสาธารณะของ DPIP โดยส่งเพียงเงื่อนไขการค้นหา (เช่น รหัสพื้นที่หรือวันที่) ไม่รวมคีย์และบทสนทนา เพื่อให้ AI รู้ว่า \"ที่นี่\" คือที่ไหน แอปจะบอกตำบลปัจจุบันและตำบลที่คุณบันทึกไว้ (เฉพาะชื่อและรหัส) ให้ AI ทราบ'; + + @override + String get aiIntroCautionTitle => 'AI อาจผิดพลาดได้'; + + @override + String get aiIntroCautionBody => + 'คำตอบมีไว้เพื่อการอ้างอิงเท่านั้น หากอาจมีอันตราย ให้ปกป้องตัวเองก่อน และปฏิบัติตามประกาศเตือนภัยและคำแนะนำอย่างเป็นทางการจากสำนักงานอุตุนิยมวิทยากลางและหน่วยงานรัฐ'; + + @override + String get aiIntroMcpTitle => 'สร้างเองได้'; + + @override + String get aiIntroMcpBody => + 'ข้อมูลของ DPIP ยังเปิดให้ใช้ในรูปแบบเซิร์ฟเวอร์ MCP (อ่านอย่างเดียว ไม่ต้องใช้คีย์) เชื่อมต่อกับ Claude, ChatGPT หรือเครื่องมืออื่นที่รองรับ MCP เพื่อสร้างสิ่งของคุณเองได้'; + + @override + String get aiIntroStart => 'เริ่มตั้งค่า'; + + @override + String get aiMcpTitle => 'เซิร์ฟเวอร์ MCP ของ DPIP'; + + @override + String get aiMcpBody => + 'ไคลเอนต์ MCP ที่รองรับ Streamable HTTP ใช้ที่อยู่นี้ได้ทันที'; + + @override + String get aiMcpCopyUrl => 'คัดลอก URL'; + + @override + String get aiMcpCopyConfig => 'คัดลอกตัวอย่างการตั้งค่า'; + + @override + String get aiMcpCopied => 'คัดลอกแล้ว'; + + @override + String get aiSettingsTitle => 'การตั้งค่า AI'; + + @override + String get aiSettingsService => 'บริการ AI'; + + @override + String get aiSettingsServiceBody => + 'ใช้ได้กับบริการที่รองรับรูปแบบ OpenAI (/chat/completions) และการเรียกใช้เครื่องมือ แตะบริการด้านล่างเพื่อกรอก URL ให้อัตโนมัติ'; + + @override + String get aiSettingsBaseUrl => 'URL ของ API'; + + @override + String get aiSettingsBaseUrlHelper => + 'URL หลักของ API โดยทั่วไปลงท้ายด้วย /v1'; + + @override + String get aiSettingsUrlEmpty => 'กรุณากรอก URL'; + + @override + String get aiSettingsUrlInvalid => 'URL ไม่ถูกต้อง'; + + @override + String get aiSettingsUrlInsecure => + 'กรุณาใช้ https:// ใช้ http:// ได้เฉพาะกับโทรศัพท์เครื่องนี้เอง (localhost)'; + + @override + String get aiSettingsApiKey => 'คีย์ API'; + + @override + String get aiSettingsApiKeyHelper => + 'เก็บไว้ในที่เก็บคีย์ของโทรศัพท์เครื่องนี้เท่านั้น เว้นว่างได้หากบริการไม่ต้องใช้คีย์'; + + @override + String get aiSettingsApiKeySaved => + 'บันทึกคีย์แล้ว เว้นว่างไว้เพื่อใช้คีย์เดิม'; + + @override + String get aiSettingsShowKey => 'แสดงคีย์'; + + @override + String get aiSettingsHideKey => 'ซ่อนคีย์'; + + @override + String get aiSettingsRemoveKey => 'ลบคีย์ที่บันทึกไว้'; + + @override + String get aiSettingsKeyRemoved => 'ลบคีย์แล้ว'; + + @override + String get aiSettingsModel => 'โมเดล'; + + @override + String get aiSettingsModelHelper => + 'ต้องเป็นโมเดลที่รองรับการเรียกใช้เครื่องมือ (function calling) แตะปุ่มด้านขวาเพื่อดูรายการโมเดล'; + + @override + String get aiSettingsModelEmpty => 'กรุณากรอกหรือเลือกโมเดล'; + + @override + String get aiSettingsFetchModels => 'ดูรายการโมเดล'; + + @override + String get aiSettingsFilterModels => 'ค้นหาโมเดล'; + + @override + String get aiSettingsNoModels => + 'บริการนี้ไม่ได้แสดงรายการโมเดล กรุณาพิมพ์ชื่อโมเดลเอง'; + + @override + String get aiSettingsSave => 'บันทึก'; + + @override + String get aiSettingsSaved => 'บันทึกแล้ว'; + + @override + String get aiSettingsSaveFailed => 'บันทึกคีย์ไม่สำเร็จ โปรดลองอีกครั้ง'; + + @override + String get aiSettingsPrivacy => 'ความเป็นส่วนตัว'; + + @override + String get aiSettingsPrivacyBody => + 'คีย์อยู่ในที่เก็บคีย์ของโทรศัพท์เครื่องนี้และไม่ถูกอัปโหลดไปยัง ExpTech บทสนทนาส่งระหว่างโทรศัพท์เครื่องนี้กับบริการ AI ของคุณเท่านั้นและไม่ถูกบันทึก เมื่อ AI ค้นข้อมูล จะส่งเพียงเงื่อนไขการค้นหาไปยังเซิร์ฟเวอร์ข้อมูลของ DPIP'; + + @override + String get aiSettingsForget => 'ล้างการตั้งค่า AI ทั้งหมด'; + + @override + String get aiSettingsForgetTitle => 'ล้างการตั้งค่า AI ไหม?'; + + @override + String get aiSettingsForgetBody => + 'URL โมเดล และคีย์จะถูกลบออกจากโทรศัพท์เครื่องนี้'; + + @override + String get aiSettingsForgetConfirm => 'ล้าง'; + + @override + String get aiSettingsForgotten => 'ล้างการตั้งค่า AI แล้ว'; + + @override + String get aiToolEarthquake => 'ข้อมูลแผ่นดินไหว'; + + @override + String get aiToolWeather => 'ข้อมูลสภาพอากาศ'; + + @override + String get aiToolTyphoon => 'ข้อมูลพายุไต้ฝุ่น'; + + @override + String get aiToolTsunami => 'ข้อมูลสึนามิ'; + + @override + String get aiToolNotices => 'ประกาศภัยพิบัติ'; + + @override + String get aiToolPlaces => 'สถานที่'; + + @override + String get aiToolTimeline => 'เวลาของข้อมูล'; + + @override + String get aiToolOther => 'ข้อมูล'; + + @override + String aiToolRunning(String subject) { + return 'กำลังค้นหา$subject…'; + } + + @override + String aiToolDone(String subject) { + return 'ค้นหา$subjectแล้ว'; + } + + @override + String aiToolFailed(String subject) { + return 'ค้นหา$subjectไม่สำเร็จ'; + } + + @override + String get aiErrorUnauthorized => + 'คีย์ API ไม่ถูกต้องหรือหมดอายุ หรือไม่มีสิทธิ์ใช้โมเดลนี้'; + + @override + String get aiErrorNotFound => + 'ไม่พบ API หรือโมเดล กรุณาตรวจสอบ URL และชื่อโมเดล'; + + @override + String get aiErrorRateLimited => + 'ถึงขีดจำกัดอัตราการใช้หรือโควตาของบริการ AI แล้ว โปรดลองใหม่ภายหลังหรือตรวจสอบแพ็กเกจ'; + + @override + String get aiErrorRejected => + 'บริการ AI ปฏิเสธคำขอ โมเดลที่เลือกอาจไม่รองรับการเรียกใช้เครื่องมือ'; + + @override + String get aiErrorServer => 'บริการ AI ขัดข้องชั่วคราว โปรดลองใหม่ภายหลัง'; + + @override + String get aiErrorNetwork => + 'เชื่อมต่อบริการ AI ไม่ได้ กรุณาตรวจสอบการเชื่อมต่อ'; + + @override + String get aiErrorTimeout => 'บริการ AI ใช้เวลาตอบนานเกินไป'; + + @override + String get aiErrorBadResponse => + 'อ่านคำตอบของบริการ AI ไม่ได้ URL นี้อาจไม่ใช่ API ที่รองรับรูปแบบ OpenAI'; + + @override + String get aiErrorToolsUnavailable => + 'เชื่อมต่อเซิร์ฟเวอร์ข้อมูล DPIP ไม่ได้ โปรดลองใหม่ภายหลัง'; + + @override + String get aiErrorTooManySteps => + 'AI ค้นข้อมูลหลายครั้งเกินไปแต่ยังไม่ได้คำตอบ ลองถามให้เจาะจงขึ้น'; + + @override + String aiErrorServiceSaid(String detail) { + return 'ข้อความจากบริการ: $detail'; + } } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 6bd54c51e..2c64a611e 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3637,4 +3637,312 @@ class AppLocalizationsVi extends AppLocalizations { @override String get onboardingNotifySkip => 'Để sau'; + + @override + String get aiChatTitle => 'Trợ lý AI'; + + @override + String get aiChatNew => 'Cuộc trò chuyện mới'; + + @override + String get aiChatAbout => 'Giới thiệu'; + + @override + String get aiChatInputHint => 'Hỏi về động đất, thời tiết hoặc bão…'; + + @override + String get aiChatSend => 'Gửi'; + + @override + String get aiChatStop => 'Dừng'; + + @override + String get aiChatDisclaimer => + 'AI có thể sai. Khi khẩn cấp, hãy làm theo cảnh báo chính thức.'; + + @override + String get aiChatEmptyTitle => 'Bạn muốn biết điều gì?'; + + @override + String get aiChatSuggestionQuakes => 'Gần đây có động đất không?'; + + @override + String get aiChatSuggestionRain => 'Hôm nay chỗ tôi có mưa không?'; + + @override + String get aiChatSuggestionTyphoon => 'Hiện có bão không?'; + + @override + String get aiChatSuggestionAlerts => 'Hiện có những cảnh báo thiên tai nào?'; + + @override + String get aiChatThinking => 'Đang suy nghĩ…'; + + @override + String get aiChatWaiting => 'Đang chờ phản hồi…'; + + @override + String get aiChatStopped => 'Đã dừng'; + + @override + String get aiChatOpenSettings => 'Mở cài đặt'; + + @override + String get aiChatCopied => 'Đã sao chép'; + + @override + String get aiChatSetupTitle => 'Chưa thiết lập dịch vụ AI'; + + @override + String get aiChatSetupBody => + 'Nhập URL, khóa và mô hình của dịch vụ AI của bạn để bắt đầu.'; + + @override + String get aiChatSetupAction => 'Thiết lập'; + + @override + String get aiIntroTitle => 'Trợ lý AI'; + + @override + String get aiIntroBody => + 'Hỏi về các trận động đất gần đây, thời tiết ngày mai hay đường đi của bão. AI sẽ tra dữ liệu DPIP theo thời gian thực trước rồi trả lời bằng ngôn ngữ của bạn.'; + + @override + String get aiIntroOwnKeyTitle => 'Dùng dịch vụ AI của riêng bạn'; + + @override + String get aiIntroOwnKeyBody => + 'Nhập URL và khóa của bất kỳ dịch vụ AI tương thích OpenAI có hỗ trợ gọi công cụ, như OpenAI, Google Gemini, Anthropic hoặc OpenRouter. Lượng sử dụng và chi phí do dịch vụ AI của bạn tính.'; + + @override + String get aiIntroLocalTitle => + 'Khóa chỉ ở trên điện thoại này; cuộc trò chuyện không được lưu'; + + @override + String get aiIntroLocalBody => + 'Khóa API được lưu trong kho khóa của hệ thống (iOS Keychain, Android Keystore), không bao giờ được tải lên ExpTech và không theo bản sao lưu sang điện thoại khác. Cuộc trò chuyện không được lưu và sẽ mất khi đóng ứng dụng.'; + + @override + String get aiIntroDataTitle => 'Dữ liệu được gửi đi đâu'; + + @override + String get aiIntroDataBody => + 'Câu hỏi của bạn được gửi thẳng từ điện thoại tới dịch vụ AI bạn thiết lập, không qua ExpTech. Khi AI cần dữ liệu, ứng dụng sẽ hỏi máy chủ dữ liệu công khai của DPIP, chỉ gửi điều kiện truy vấn (như mã khu vực hoặc ngày), không kèm khóa hay cuộc trò chuyện. Để AI biết \"chỗ tôi\" là đâu, vị trí hiện tại và các thị trấn đã lưu trong ứng dụng (chỉ tên và mã) cũng được gửi cho AI.'; + + @override + String get aiIntroCautionTitle => 'AI có thể sai'; + + @override + String get aiIntroCautionBody => + 'Câu trả lời chỉ mang tính tham khảo. Khi có thể gặp nguy hiểm, hãy bảo vệ bản thân trước và làm theo cảnh báo, chỉ dẫn chính thức của Cục Khí tượng Trung ương và chính quyền.'; + + @override + String get aiIntroMcpTitle => 'Tự xây dựng'; + + @override + String get aiIntroMcpBody => + 'Dữ liệu của DPIP cũng được mở dưới dạng máy chủ MCP — chỉ đọc, không cần khóa. Hãy kết nối với Claude, ChatGPT hoặc công cụ hỗ trợ MCP khác để tự xây dựng ứng dụng của bạn.'; + + @override + String get aiIntroStart => 'Bắt đầu thiết lập'; + + @override + String get aiMcpTitle => 'Máy chủ MCP của DPIP'; + + @override + String get aiMcpBody => + 'Mọi ứng dụng khách MCP hỗ trợ Streamable HTTP đều dùng được địa chỉ này.'; + + @override + String get aiMcpCopyUrl => 'Sao chép URL'; + + @override + String get aiMcpCopyConfig => 'Sao chép cấu hình mẫu'; + + @override + String get aiMcpCopied => 'Đã sao chép'; + + @override + String get aiSettingsTitle => 'Cài đặt AI'; + + @override + String get aiSettingsService => 'Dịch vụ AI'; + + @override + String get aiSettingsServiceBody => + 'Dùng được với mọi dịch vụ tương thích OpenAI (/chat/completions) có hỗ trợ gọi công cụ. Chạm vào một dịch vụ bên dưới để điền URL.'; + + @override + String get aiSettingsBaseUrl => 'URL API'; + + @override + String get aiSettingsBaseUrlHelper => + 'URL gốc của API, thường kết thúc bằng /v1.'; + + @override + String get aiSettingsUrlEmpty => 'Hãy nhập URL'; + + @override + String get aiSettingsUrlInvalid => 'URL không hợp lệ'; + + @override + String get aiSettingsUrlInsecure => + 'Hãy dùng https://. Chỉ được dùng http:// cho chính điện thoại này (localhost).'; + + @override + String get aiSettingsApiKey => 'Khóa API'; + + @override + String get aiSettingsApiKeyHelper => + 'Chỉ được lưu trong kho khóa của điện thoại này. Để trống nếu dịch vụ không cần khóa.'; + + @override + String get aiSettingsApiKeySaved => + 'Đã lưu khóa. Để trống để giữ khóa hiện tại.'; + + @override + String get aiSettingsShowKey => 'Hiện khóa'; + + @override + String get aiSettingsHideKey => 'Ẩn khóa'; + + @override + String get aiSettingsRemoveKey => 'Xóa khóa đã lưu'; + + @override + String get aiSettingsKeyRemoved => 'Đã xóa khóa'; + + @override + String get aiSettingsModel => 'Mô hình'; + + @override + String get aiSettingsModelHelper => + 'Cần mô hình hỗ trợ gọi công cụ (function calling). Nút bên phải sẽ liệt kê các mô hình của dịch vụ.'; + + @override + String get aiSettingsModelEmpty => 'Hãy nhập hoặc chọn mô hình'; + + @override + String get aiSettingsFetchModels => 'Lấy danh sách mô hình'; + + @override + String get aiSettingsFilterModels => 'Lọc mô hình'; + + @override + String get aiSettingsNoModels => + 'Dịch vụ này không liệt kê mô hình nào. Hãy nhập tên mô hình.'; + + @override + String get aiSettingsSave => 'Lưu'; + + @override + String get aiSettingsSaved => 'Đã lưu'; + + @override + String get aiSettingsSaveFailed => 'Không lưu được khóa. Vui lòng thử lại.'; + + @override + String get aiSettingsPrivacy => 'Quyền riêng tư'; + + @override + String get aiSettingsPrivacyBody => + 'Khóa chỉ nằm trong kho khóa của điện thoại này và không bao giờ được tải lên ExpTech. Cuộc trò chuyện chỉ truyền giữa điện thoại này và dịch vụ AI của bạn, và không được lưu. Khi AI tra dữ liệu, chỉ điều kiện truy vấn được gửi tới máy chủ dữ liệu của DPIP.'; + + @override + String get aiSettingsForget => 'Xóa toàn bộ cài đặt AI'; + + @override + String get aiSettingsForgetTitle => 'Xóa cài đặt AI?'; + + @override + String get aiSettingsForgetBody => + 'URL, mô hình và khóa sẽ bị xóa khỏi điện thoại này.'; + + @override + String get aiSettingsForgetConfirm => 'Xóa'; + + @override + String get aiSettingsForgotten => 'Đã xóa cài đặt AI'; + + @override + String get aiToolEarthquake => 'dữ liệu động đất'; + + @override + String get aiToolWeather => 'dữ liệu thời tiết'; + + @override + String get aiToolTyphoon => 'dữ liệu bão'; + + @override + String get aiToolTsunami => 'dữ liệu sóng thần'; + + @override + String get aiToolNotices => 'thông báo thiên tai'; + + @override + String get aiToolPlaces => 'địa điểm'; + + @override + String get aiToolTimeline => 'thời điểm dữ liệu'; + + @override + String get aiToolOther => 'dữ liệu'; + + @override + String aiToolRunning(String subject) { + return 'Đang tra $subject…'; + } + + @override + String aiToolDone(String subject) { + return 'Đã tra $subject'; + } + + @override + String aiToolFailed(String subject) { + return 'Không tra được $subject'; + } + + @override + String get aiErrorUnauthorized => + 'Khóa API không hợp lệ, đã hết hạn hoặc không có quyền dùng mô hình này.'; + + @override + String get aiErrorNotFound => + 'Không tìm thấy API hoặc mô hình. Hãy kiểm tra URL và tên mô hình.'; + + @override + String get aiErrorRateLimited => + 'Đã chạm giới hạn tốc độ hoặc hạn mức của dịch vụ AI. Hãy thử lại sau hoặc kiểm tra gói của bạn.'; + + @override + String get aiErrorRejected => + 'Dịch vụ AI đã từ chối yêu cầu. Mô hình có thể không hỗ trợ gọi công cụ.'; + + @override + String get aiErrorServer => 'Dịch vụ AI tạm thời gặp lỗi. Hãy thử lại sau.'; + + @override + String get aiErrorNetwork => + 'Không kết nối được dịch vụ AI. Hãy kiểm tra mạng.'; + + @override + String get aiErrorTimeout => 'Dịch vụ AI phản hồi quá lâu.'; + + @override + String get aiErrorBadResponse => + 'Không đọc được phản hồi của dịch vụ AI. URL này có thể không phải API tương thích OpenAI.'; + + @override + String get aiErrorToolsUnavailable => + 'Không kết nối được máy chủ dữ liệu DPIP. Hãy thử lại sau.'; + + @override + String get aiErrorTooManySteps => + 'AI đã tra dữ liệu quá nhiều lần mà chưa có câu trả lời. Hãy hỏi cụ thể hơn.'; + + @override + String aiErrorServiceSaid(String detail) { + return 'Thông báo từ dịch vụ: $detail'; + } } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index 90bee844e..888bef9bb 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3542,4 +3542,294 @@ class AppLocalizationsYue extends AppLocalizations { @override String get onboardingNotifySkip => '跳過,遲啲先設定'; + + @override + String get aiChatTitle => 'AI 助理'; + + @override + String get aiChatNew => '新對話'; + + @override + String get aiChatAbout => '說明'; + + @override + String get aiChatInputHint => '問下地震、天氣或者颱風…'; + + @override + String get aiChatSend => '傳送'; + + @override + String get aiChatStop => '停止'; + + @override + String get aiChatDisclaimer => 'AI 有機會出錯,緊急時請以官方警報為準。'; + + @override + String get aiChatEmptyTitle => '想知啲乜?'; + + @override + String get aiChatSuggestionQuakes => '最近有冇地震?'; + + @override + String get aiChatSuggestionRain => '我呢度今日會唔會落雨?'; + + @override + String get aiChatSuggestionTyphoon => '而家有冇颱風?'; + + @override + String get aiChatSuggestionAlerts => '而家有咩災害警報?'; + + @override + String get aiChatThinking => '諗緊…'; + + @override + String get aiChatWaiting => '等緊回覆…'; + + @override + String get aiChatStopped => '已停止'; + + @override + String get aiChatOpenSettings => '開啟設定'; + + @override + String get aiChatCopied => '已複製'; + + @override + String get aiChatSetupTitle => '未設定 AI 服務'; + + @override + String get aiChatSetupBody => '填入你自己嘅 AI 服務網址、金鑰同模型就可以開始。'; + + @override + String get aiChatSetupAction => '去設定'; + + @override + String get aiIntroTitle => 'AI 助理'; + + @override + String get aiIntroBody => '問下最近嘅地震、聽日嘅天氣或者颱風動向。AI 會先即時查 DPIP 嘅資料,再用你嘅語言答你。'; + + @override + String get aiIntroOwnKeyTitle => '用你自己嘅 AI 服務'; + + @override + String get aiIntroOwnKeyBody => + '填入任何兼容 OpenAI 格式、支援工具呼叫嘅 AI 服務網址同金鑰,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量同費用由你嘅 AI 服務計。'; + + @override + String get aiIntroLocalTitle => '金鑰只會存喺呢部手機,對話唔會儲存'; + + @override + String get aiIntroLocalBody => + 'API 金鑰存喺系統嘅金鑰庫(iOS 鑰匙圈、Android Keystore),唔會上載去 ExpTech,亦唔會跟備份還原去第二部手機。對話唔會儲存,閂咗 App 就會消失。'; + + @override + String get aiIntroDataTitle => '資料點樣傳送'; + + @override + String get aiIntroDataBody => + '你嘅問題會由手機直接傳去你設定嘅 AI 服務,唔經 ExpTech。AI 要資料嗰陣,App 會向 DPIP 嘅公開資料伺服器查,只會傳查詢條件(例如地區代碼或者日期),唔包金鑰同對話。為咗知道「我呢度」係邊度,你喺 App 入面嘅所在地同收藏嘅鄉鎮(只有名稱同代碼)會一齊話俾 AI 知。'; + + @override + String get aiIntroCautionTitle => 'AI 有機會出錯'; + + @override + String get aiIntroCautionBody => + 'AI 嘅答案只供參考。有機會有危險嗰陣,請先保護自己,並以中央氣象署同政府嘅官方警報同指示為準。'; + + @override + String get aiIntroMcpTitle => '自己整'; + + @override + String get aiIntroMcpBody => + 'DPIP 嘅資料亦都以 MCP 伺服器形式開放,唯讀、唔使金鑰。可以駁去 Claude、ChatGPT 或者其他支援 MCP 嘅工具,整你自己嘅應用。'; + + @override + String get aiIntroStart => '開始設定'; + + @override + String get aiMcpTitle => 'DPIP MCP 伺服器'; + + @override + String get aiMcpBody => '支援 Streamable HTTP 嘅 MCP 用戶端都可以直接用呢個網址。'; + + @override + String get aiMcpCopyUrl => '複製網址'; + + @override + String get aiMcpCopyConfig => '複製設定範例'; + + @override + String get aiMcpCopied => '已複製'; + + @override + String get aiSettingsTitle => 'AI 設定'; + + @override + String get aiSettingsService => 'AI 服務'; + + @override + String get aiSettingsServiceBody => + '任何兼容 OpenAI 格式(/chat/completions)同支援工具呼叫嘅服務都得。㩒下面嘅服務會自動填入網址。'; + + @override + String get aiSettingsBaseUrl => 'API 網址'; + + @override + String get aiSettingsBaseUrlHelper => 'API 嘅根網址,通常以 /v1 結尾。'; + + @override + String get aiSettingsUrlEmpty => '請輸入網址'; + + @override + String get aiSettingsUrlInvalid => '呢個唔係有效嘅網址'; + + @override + String get aiSettingsUrlInsecure => + '請用 https://。只有駁去呢部手機本身(localhost)先可以用 http://。'; + + @override + String get aiSettingsApiKey => 'API 金鑰'; + + @override + String get aiSettingsApiKeyHelper => '只會存喺呢部手機嘅系統金鑰庫。唔使金鑰嘅服務可以留空。'; + + @override + String get aiSettingsApiKeySaved => '已經儲存咗金鑰。留空會保留原本嘅金鑰。'; + + @override + String get aiSettingsShowKey => '顯示金鑰'; + + @override + String get aiSettingsHideKey => '隱藏金鑰'; + + @override + String get aiSettingsRemoveKey => '刪除已儲存嘅金鑰'; + + @override + String get aiSettingsKeyRemoved => '已刪除金鑰'; + + @override + String get aiSettingsModel => '模型'; + + @override + String get aiSettingsModelHelper => + '要支援工具呼叫(function calling)嘅模型。㩒右邊個掣可以攞服務嘅模型清單。'; + + @override + String get aiSettingsModelEmpty => '請輸入或者揀模型'; + + @override + String get aiSettingsFetchModels => '攞模型清單'; + + @override + String get aiSettingsFilterModels => '搵模型'; + + @override + String get aiSettingsNoModels => '呢個服務冇列出任何模型,請直接輸入名稱。'; + + @override + String get aiSettingsSave => '儲存'; + + @override + String get aiSettingsSaved => '已儲存'; + + @override + String get aiSettingsSaveFailed => '儲存唔到金鑰,請再試一次。'; + + @override + String get aiSettingsPrivacy => '私隱'; + + @override + String get aiSettingsPrivacyBody => + '金鑰只會存喺呢部手機嘅系統金鑰庫,唔會上載去 ExpTech。對話只會喺手機同你嘅 AI 服務之間傳送,唔會儲存。AI 查資料嗰陣,只有查詢條件會傳去 DPIP 嘅資料伺服器。'; + + @override + String get aiSettingsForget => '清除所有 AI 設定'; + + @override + String get aiSettingsForgetTitle => '清除 AI 設定?'; + + @override + String get aiSettingsForgetBody => '會由呢部手機刪除網址、模型同金鑰。'; + + @override + String get aiSettingsForgetConfirm => '清除'; + + @override + String get aiSettingsForgotten => '已清除 AI 設定'; + + @override + String get aiToolEarthquake => '地震資料'; + + @override + String get aiToolWeather => '天氣資料'; + + @override + String get aiToolTyphoon => '颱風資料'; + + @override + String get aiToolTsunami => '海嘯資料'; + + @override + String get aiToolNotices => '災害通知'; + + @override + String get aiToolPlaces => '地點'; + + @override + String get aiToolTimeline => '資料時間'; + + @override + String get aiToolOther => '資料'; + + @override + String aiToolRunning(String subject) { + return '查緊$subject…'; + } + + @override + String aiToolDone(String subject) { + return '查咗$subject'; + } + + @override + String aiToolFailed(String subject) { + return '查唔到$subject'; + } + + @override + String get aiErrorUnauthorized => 'API 金鑰無效、過咗期,或者冇用呢個模型嘅權限。'; + + @override + String get aiErrorNotFound => '搵唔到 API 或者模型,請檢查網址同模型名稱。'; + + @override + String get aiErrorRateLimited => '已經到咗 AI 服務嘅速率限制或者額度上限,請遲啲再試或者檢查計劃。'; + + @override + String get aiErrorRejected => 'AI 服務拒絕咗呢個請求,揀咗嘅模型可能唔支援工具呼叫。'; + + @override + String get aiErrorServer => 'AI 服務暫時出錯,請遲啲再試。'; + + @override + String get aiErrorNetwork => '連唔到 AI 服務,請檢查網絡。'; + + @override + String get aiErrorTimeout => 'AI 服務太耐冇回應。'; + + @override + String get aiErrorBadResponse => '認唔到 AI 服務嘅回應,呢個網址可能唔係兼容 OpenAI 格式嘅 API。'; + + @override + String get aiErrorToolsUnavailable => '暫時連唔到 DPIP 資料伺服器,請遲啲再試。'; + + @override + String get aiErrorTooManySteps => 'AI 查咗太多次資料都未有結論,請問得具體啲。'; + + @override + String aiErrorServiceSaid(String detail) { + return '服務嘅訊息:$detail'; + } } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 1aa4fcda5..cfa54a4c4 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3542,6 +3542,296 @@ class AppLocalizationsZh extends AppLocalizations { @override String get onboardingNotifySkip => '略過,之後再設定'; + + @override + String get aiChatTitle => 'AI 助理'; + + @override + String get aiChatNew => '新對話'; + + @override + String get aiChatAbout => '說明'; + + @override + String get aiChatInputHint => '問問地震、天氣或颱風…'; + + @override + String get aiChatSend => '傳送'; + + @override + String get aiChatStop => '停止'; + + @override + String get aiChatDisclaimer => 'AI 可能出錯,緊急時請以官方警報為準。'; + + @override + String get aiChatEmptyTitle => '想知道什麼?'; + + @override + String get aiChatSuggestionQuakes => '最近有地震嗎?'; + + @override + String get aiChatSuggestionRain => '我這裡今天會下雨嗎?'; + + @override + String get aiChatSuggestionTyphoon => '現在有颱風嗎?'; + + @override + String get aiChatSuggestionAlerts => '目前有哪些災害警報?'; + + @override + String get aiChatThinking => '思考中…'; + + @override + String get aiChatWaiting => '等待回應…'; + + @override + String get aiChatStopped => '已停止'; + + @override + String get aiChatOpenSettings => '開啟設定'; + + @override + String get aiChatCopied => '已複製'; + + @override + String get aiChatSetupTitle => '還沒設定 AI 服務'; + + @override + String get aiChatSetupBody => '填入你自己的 AI 服務網址、金鑰和模型就可以開始。'; + + @override + String get aiChatSetupAction => '前往設定'; + + @override + String get aiIntroTitle => 'AI 助理'; + + @override + String get aiIntroBody => '問問最近的地震、明天的天氣或颱風動態。AI 會先即時查詢 DPIP 的資料,再用你的語言回答。'; + + @override + String get aiIntroOwnKeyTitle => '使用你自己的 AI 服務'; + + @override + String get aiIntroOwnKeyBody => + '填入任何相容 OpenAI 格式、支援工具呼叫的 AI 服務網址和金鑰,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量和費用由你的 AI 服務計算。'; + + @override + String get aiIntroLocalTitle => '金鑰只存在這支手機,對話不會儲存'; + + @override + String get aiIntroLocalBody => + 'API 金鑰存在系統的金鑰庫(iOS 鑰匙圈、Android Keystore),不會上傳到 ExpTech,也不會隨備份還原到其他手機。對話不會被儲存,關掉 App 就會消失。'; + + @override + String get aiIntroDataTitle => '資料怎麼傳送'; + + @override + String get aiIntroDataBody => + '你的問題會從手機直接送到你設定的 AI 服務,不經過 ExpTech。AI 需要資料時,App 會向 DPIP 的公開資料伺服器查詢,只送出查詢條件(例如地區代碼或日期),不含金鑰和對話。為了知道「我這裡」是哪裡,你在 App 裡的所在地和收藏的鄉鎮(只有名稱和代碼)會一起告訴 AI。'; + + @override + String get aiIntroCautionTitle => 'AI 可能出錯'; + + @override + String get aiIntroCautionBody => + 'AI 的回答僅供參考。可能有危險時,請先保護自己,並以中央氣象署和政府的官方警報與指示為準。'; + + @override + String get aiIntroMcpTitle => '自己動手做'; + + @override + String get aiIntroMcpBody => + 'DPIP 的資料也開放成 MCP 伺服器,唯讀、不需要金鑰。可以接到 Claude、ChatGPT 或其他支援 MCP 的工具,做出你自己的應用。'; + + @override + String get aiIntroStart => '開始設定'; + + @override + String get aiMcpTitle => 'DPIP MCP 伺服器'; + + @override + String get aiMcpBody => '支援 Streamable HTTP 的 MCP 用戶端都可以直接使用這個網址。'; + + @override + String get aiMcpCopyUrl => '複製網址'; + + @override + String get aiMcpCopyConfig => '複製設定範例'; + + @override + String get aiMcpCopied => '已複製'; + + @override + String get aiSettingsTitle => 'AI 設定'; + + @override + String get aiSettingsService => 'AI 服務'; + + @override + String get aiSettingsServiceBody => + '任何相容 OpenAI 格式(/chat/completions)並支援工具呼叫的服務都可以。點選下方的服務會自動填入網址。'; + + @override + String get aiSettingsBaseUrl => 'API 網址'; + + @override + String get aiSettingsBaseUrlHelper => 'API 的根網址,通常以 /v1 結尾。'; + + @override + String get aiSettingsUrlEmpty => '請輸入網址'; + + @override + String get aiSettingsUrlInvalid => '這不是有效的網址'; + + @override + String get aiSettingsUrlInsecure => + '請使用 https://。只有連到這支手機本身(localhost)才可以用 http://。'; + + @override + String get aiSettingsApiKey => 'API 金鑰'; + + @override + String get aiSettingsApiKeyHelper => '只存在這支手機的系統金鑰庫。不需要金鑰的服務可以留空。'; + + @override + String get aiSettingsApiKeySaved => '已儲存金鑰。留空會保留原本的金鑰。'; + + @override + String get aiSettingsShowKey => '顯示金鑰'; + + @override + String get aiSettingsHideKey => '隱藏金鑰'; + + @override + String get aiSettingsRemoveKey => '刪除已儲存的金鑰'; + + @override + String get aiSettingsKeyRemoved => '已刪除金鑰'; + + @override + String get aiSettingsModel => '模型'; + + @override + String get aiSettingsModelHelper => + '需要支援工具呼叫(function calling)的模型。點右邊的按鈕可以取得服務的模型清單。'; + + @override + String get aiSettingsModelEmpty => '請輸入或選擇模型'; + + @override + String get aiSettingsFetchModels => '取得模型清單'; + + @override + String get aiSettingsFilterModels => '搜尋模型'; + + @override + String get aiSettingsNoModels => '這個服務沒有列出任何模型,請直接輸入名稱。'; + + @override + String get aiSettingsSave => '儲存'; + + @override + String get aiSettingsSaved => '已儲存'; + + @override + String get aiSettingsSaveFailed => '無法儲存金鑰,請再試一次。'; + + @override + String get aiSettingsPrivacy => '隱私'; + + @override + String get aiSettingsPrivacyBody => + '金鑰只存在這支手機的系統金鑰庫,不會上傳到 ExpTech。對話只在手機和你的 AI 服務之間傳送,不會被儲存。AI 查資料時,只有查詢條件會送到 DPIP 的資料伺服器。'; + + @override + String get aiSettingsForget => '清除所有 AI 設定'; + + @override + String get aiSettingsForgetTitle => '清除 AI 設定?'; + + @override + String get aiSettingsForgetBody => '會從這支手機刪除網址、模型和金鑰。'; + + @override + String get aiSettingsForgetConfirm => '清除'; + + @override + String get aiSettingsForgotten => '已清除 AI 設定'; + + @override + String get aiToolEarthquake => '地震資料'; + + @override + String get aiToolWeather => '天氣資料'; + + @override + String get aiToolTyphoon => '颱風資料'; + + @override + String get aiToolTsunami => '海嘯資料'; + + @override + String get aiToolNotices => '災害通知'; + + @override + String get aiToolPlaces => '地點'; + + @override + String get aiToolTimeline => '資料時間'; + + @override + String get aiToolOther => '資料'; + + @override + String aiToolRunning(String subject) { + return '正在查詢$subject…'; + } + + @override + String aiToolDone(String subject) { + return '已查詢$subject'; + } + + @override + String aiToolFailed(String subject) { + return '$subject查詢失敗'; + } + + @override + String get aiErrorUnauthorized => 'API 金鑰無效、過期,或沒有使用這個模型的權限。'; + + @override + String get aiErrorNotFound => '找不到 API 或模型,請檢查網址和模型名稱。'; + + @override + String get aiErrorRateLimited => '已達到 AI 服務的速率限制或額度上限,請稍後再試或檢查方案。'; + + @override + String get aiErrorRejected => 'AI 服務拒絕了這個請求,選擇的模型可能不支援工具呼叫。'; + + @override + String get aiErrorServer => 'AI 服務暫時出錯,請稍後再試。'; + + @override + String get aiErrorNetwork => '無法連上 AI 服務,請檢查網路。'; + + @override + String get aiErrorTimeout => 'AI 服務太久沒有回應。'; + + @override + String get aiErrorBadResponse => '無法辨識 AI 服務的回應,這個網址可能不是相容 OpenAI 格式的 API。'; + + @override + String get aiErrorToolsUnavailable => '暫時無法連上 DPIP 資料伺服器,請稍後再試。'; + + @override + String get aiErrorTooManySteps => 'AI 查了太多次資料還沒有結論,請把問題問得更具體。'; + + @override + String aiErrorServiceSaid(String detail) { + return '服務的訊息:$detail'; + } } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -7078,186 +7368,476 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get onboardingNotifySkip => '跳过,稍后再设置'; -} -/// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). -class AppLocalizationsZhHantHk extends AppLocalizationsZh { - AppLocalizationsZhHantHk() : super('zh_Hant_HK'); + @override + String get aiChatTitle => 'AI 助手'; @override - String typhoonValueLat(String lat) { - return '北緯 $lat 度'; - } + String get aiChatNew => '新对话'; @override - String get onboardingSkipBody => - '未授權定位與通知,DPIP 將無法即時通知你所在地的地震與災害。你仍可稍後在設定中開啟。'; + String get aiChatAbout => '说明'; @override - String get rainInterval24h => '24 時'; + String get aiChatInputHint => '问问地震、天气或台风…'; @override - String homeRainTrendHeavyStopping(int minutes) { - return '預計 $minutes 分鐘後停止下大雨'; - } + String get aiChatSend => '发送'; @override - String get mapTimelineObserved => '觀測'; + String get aiChatStop => '停止'; @override - String get mapTimelineScrubPaused => '拖動過快,影格更新已暫停;放慢速度即可恢復。'; + String get aiChatDisclaimer => 'AI 可能出错,紧急时请以官方警报为准。'; @override - String get regionSelectTitle => '選擇地區'; + String get aiChatEmptyTitle => '想了解什么?'; @override - String get skyTimeNoon => '正午'; + String get aiChatSuggestionQuakes => '最近有地震吗?'; @override - String get radarCountyOutlineSubtitle => '讓縣市界線在雷達回波下仍然清楚。'; + String get aiChatSuggestionRain => '我这里今天会下雨吗?'; @override - String get mapLayerSatelliteB03 => 'ひまわり 可見光-紅(B03)'; + String get aiChatSuggestionTyphoon => '现在有台风吗?'; @override - String get reportFilterIntensity => '震度'; + String get aiChatSuggestionAlerts => '目前有哪些灾害警报?'; @override - String get mapLayerLightning => '閃電'; + String get aiChatThinking => '思考中…'; @override - String get restroomTypeMale => '男廁所'; + String get aiChatWaiting => '等待回复…'; @override - String get meshtasticLastReceived => '最近接收'; + String get aiChatStopped => '已停止'; @override - String get reportDetailSortByCounty => '依縣市排序'; + String get aiChatOpenSettings => '打开设置'; @override - String get onboardingPermUnusedApp => '保持 App 啟用'; + String get aiChatCopied => '已复制'; @override - String get onboardingPermUnusedAppDesc => - 'Android 會暫停你長期未開啟嘅 App 並撤銷佢哋嘅權限,噉會令災害警報無法送到你所在地。'; + String get aiChatSetupTitle => '尚未设置 AI 服务'; @override - String get onboardingPermBackgroundExec => '背景執行'; + String get aiChatSetupBody => '填入你自己的 AI 服务网址、密钥和模型即可开始。'; @override - String get onboardingPermBackgroundExecDesc => '關閉時,App 不會被喚醒回報你的位置。'; + String get aiChatSetupAction => '前往设置'; @override - String get onboardingPermVendorPower => '手機廠商省電設定'; + String get aiIntroTitle => 'AI 助手'; @override - String onboardingPermVendorPowerDesc(String brand) { - return '$brand 會停止你最近沒開過的 App 的背景作業。App 無法偵測或變更,請手動允許。'; - } + String get aiIntroBody => '问问最近的地震、明天的天气或台风动态。AI 会先实时查询 DPIP 的数据,再用你的语言回答。'; @override - String get homeRainTrendScattered => '可能會有零星降雨'; + String get aiIntroOwnKeyTitle => '使用你自己的 AI 服务'; @override - String get meshtasticUptime => '運行時間'; + String get aiIntroOwnKeyBody => + '填入任何兼容 OpenAI 格式、支持工具调用的 AI 服务网址和密钥,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量和费用由你的 AI 服务计算。'; @override - String get weatherRankingTempExtremes => '溫度極值'; + String get aiIntroLocalTitle => '密钥只保存在这部手机上,对话不会保存'; @override - String get themeLight => '淺色'; + String get aiIntroLocalBody => + 'API 密钥保存在系统的密钥库(iOS 钥匙串、Android Keystore),不会上传到 ExpTech,也不会随备份恢复到其他手机。对话不会被保存,关闭 App 后就会消失。'; @override - String get mapTerrainReliefHint => '在底圖上顯示立體地形陰影'; + String get aiIntroDataTitle => '数据如何传送'; @override - String get meshtasticEmptyMessage => '(空白訊息)'; + String get aiIntroDataBody => + '你的问题会从手机直接发送到你设置的 AI 服务,不经过 ExpTech。AI 需要数据时,App 会向 DPIP 的公开数据服务器查询,只发送查询条件(例如地区代码或日期),不包含密钥和对话。为了知道“我这里”是哪里,你在 App 里的所在地和收藏的乡镇(只有名称和代码)会一并告诉 AI。'; @override - String get moreSectionRegion => '地區'; + String get aiIntroCautionTitle => 'AI 可能出错'; @override - String get mapLayerSatellite => 'ひまわり 紅外線(B13)'; + String get aiIntroCautionBody => + 'AI 的回答仅供参考。可能有危险时,请先保护自己,并以中央气象署和政府发布的官方警报与指示为准。'; @override - String get aedHoursSaturday => '週六開放時間'; + String get aiIntroMcpTitle => '自己动手做'; @override - String get moonPhaseNew => '新月'; + String get aiIntroMcpBody => + 'DPIP 的数据也以 MCP 服务器形式开放,只读、无需密钥。可以接入 Claude、ChatGPT 或其他支持 MCP 的工具,做出你自己的应用。'; @override - String get notifySectionEew => '地震速報'; + String get aiIntroStart => '开始设置'; @override - String get mapResetNorth => '回到北方'; + String get aiMcpTitle => 'DPIP MCP 服务器'; @override - String get rainInterval2d => '2 日'; + String get aiMcpBody => '支持 Streamable HTTP 的 MCP 客户端都可以直接使用这个网址。'; @override - String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + String get aiMcpCopyUrl => '复制网址'; @override - String get commonCancel => '取消'; + String get aiMcpCopyConfig => '复制配置示例'; @override - String get notifyOptTsunamiWarning => '只接收海嘯警報'; + String get aiMcpCopied => '已复制'; @override - String get mapLayerSatelliteBtdFog => 'ひまわり 夜間霧'; + String get aiSettingsTitle => 'AI 设置'; @override - String get moreSectionAdvanced => '進階'; + String get aiSettingsService => 'AI 服务'; @override - String get moreSectionMesh => 'Mesh 網絡'; + String get aiSettingsServiceBody => + '任何兼容 OpenAI 格式(/chat/completions)并支持工具调用的服务都可以。点按下方的服务会自动填入网址。'; @override - String get weatherRankingExtremeRange => '日溫差'; + String get aiSettingsBaseUrl => 'API 网址'; @override - String get permissionsTitle => '權限檢查'; + String get aiSettingsBaseUrlHelper => 'API 的根网址,通常以 /v1 结尾。'; @override - String get permissionsAttention => '權限需要處理'; + String get aiSettingsUrlEmpty => '请输入网址'; @override - String get permissionsBody => 'DPIP 需要這些權限才能即時通知你。收不到警報時,通常就是其中一項尚未開啟。'; + String get aiSettingsUrlInvalid => '这不是有效的网址'; @override - String get notifySettingsMenu => '通知設定'; + String get aiSettingsUrlInsecure => + '请使用 https://。只有连接这部手机本身(localhost)时才能用 http://。'; @override - String mapAppDefault(String app) { - return '$app(預設)'; - } + String get aiSettingsApiKey => 'API 密钥'; @override - String get trendRange24h => '24 小時'; + String get aiSettingsApiKeyHelper => '只保存在这部手机的系统密钥库。不需要密钥的服务可以留空。'; @override - String get mapLayerStyleJmaTooltip => '灰階為底,−40 °C 以下上色,凸顯雲頂高度'; + String get aiSettingsApiKeySaved => '已保存密钥。留空则保留原来的密钥。'; @override - String get mapLayerRain => '雨量'; + String get aiSettingsShowKey => '显示密钥'; @override - String get mapLayerQpesums => '未來 1 小時降水預報'; + String get aiSettingsHideKey => '隐藏密钥'; @override - String get mapOverlaySectionMap => '地圖'; + String get aiSettingsRemoveKey => '删除已保存的密钥'; @override - String get mapTerrainRelief => '地形立體感'; + String get aiSettingsKeyRemoved => '已删除密钥'; @override - String get mapLegendCollapse => '收合圖例'; + String get aiSettingsModel => '模型'; @override - String get updateAvailableTitle => '有新版本'; + String get aiSettingsModelHelper => + '需要支持工具调用(function calling)的模型。点按右侧按钮可获取服务的模型列表。'; @override - String updateAvailableBody(String version) { + String get aiSettingsModelEmpty => '请输入或选择模型'; + + @override + String get aiSettingsFetchModels => '获取模型列表'; + + @override + String get aiSettingsFilterModels => '搜索模型'; + + @override + String get aiSettingsNoModels => '这个服务没有列出任何模型,请直接输入名称。'; + + @override + String get aiSettingsSave => '保存'; + + @override + String get aiSettingsSaved => '已保存'; + + @override + String get aiSettingsSaveFailed => '无法保存密钥,请再试一次。'; + + @override + String get aiSettingsPrivacy => '隐私'; + + @override + String get aiSettingsPrivacyBody => + '密钥只保存在这部手机的系统密钥库,不会上传到 ExpTech。对话只在手机和你的 AI 服务之间传送,不会被保存。AI 查询数据时,只有查询条件会发送到 DPIP 的数据服务器。'; + + @override + String get aiSettingsForget => '清除所有 AI 设置'; + + @override + String get aiSettingsForgetTitle => '清除 AI 设置?'; + + @override + String get aiSettingsForgetBody => '将从这部手机删除网址、模型和密钥。'; + + @override + String get aiSettingsForgetConfirm => '清除'; + + @override + String get aiSettingsForgotten => '已清除 AI 设置'; + + @override + String get aiToolEarthquake => '地震数据'; + + @override + String get aiToolWeather => '天气数据'; + + @override + String get aiToolTyphoon => '台风数据'; + + @override + String get aiToolTsunami => '海啸数据'; + + @override + String get aiToolNotices => '灾害通知'; + + @override + String get aiToolPlaces => '地点'; + + @override + String get aiToolTimeline => '数据时间'; + + @override + String get aiToolOther => '数据'; + + @override + String aiToolRunning(String subject) { + return '正在查询$subject…'; + } + + @override + String aiToolDone(String subject) { + return '已查询$subject'; + } + + @override + String aiToolFailed(String subject) { + return '$subject查询失败'; + } + + @override + String get aiErrorUnauthorized => 'API 密钥无效、已过期,或没有使用此模型的权限。'; + + @override + String get aiErrorNotFound => '找不到 API 或模型,请检查网址和模型名称。'; + + @override + String get aiErrorRateLimited => '已达到 AI 服务的速率限制或额度上限,请稍后再试或检查套餐。'; + + @override + String get aiErrorRejected => 'AI 服务拒绝了这个请求,所选模型可能不支持工具调用。'; + + @override + String get aiErrorServer => 'AI 服务暂时出错,请稍后再试。'; + + @override + String get aiErrorNetwork => '无法连接 AI 服务,请检查网络。'; + + @override + String get aiErrorTimeout => 'AI 服务太久没有响应。'; + + @override + String get aiErrorBadResponse => '无法识别 AI 服务的响应,这个网址可能不是兼容 OpenAI 格式的 API。'; + + @override + String get aiErrorToolsUnavailable => '暂时无法连接 DPIP 数据服务器,请稍后再试。'; + + @override + String get aiErrorTooManySteps => 'AI 查询了太多次数据仍没有结论,请把问题问得更具体。'; + + @override + String aiErrorServiceSaid(String detail) { + return '服务返回:$detail'; + } +} + +/// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). +class AppLocalizationsZhHantHk extends AppLocalizationsZh { + AppLocalizationsZhHantHk() : super('zh_Hant_HK'); + + @override + String typhoonValueLat(String lat) { + return '北緯 $lat 度'; + } + + @override + String get onboardingSkipBody => + '未授權定位與通知,DPIP 將無法即時通知你所在地的地震與災害。你仍可稍後在設定中開啟。'; + + @override + String get rainInterval24h => '24 時'; + + @override + String homeRainTrendHeavyStopping(int minutes) { + return '預計 $minutes 分鐘後停止下大雨'; + } + + @override + String get mapTimelineObserved => '觀測'; + + @override + String get mapTimelineScrubPaused => '拖動過快,影格更新已暫停;放慢速度即可恢復。'; + + @override + String get regionSelectTitle => '選擇地區'; + + @override + String get skyTimeNoon => '正午'; + + @override + String get radarCountyOutlineSubtitle => '讓縣市界線在雷達回波下仍然清楚。'; + + @override + String get mapLayerSatelliteB03 => 'ひまわり 可見光-紅(B03)'; + + @override + String get reportFilterIntensity => '震度'; + + @override + String get mapLayerLightning => '閃電'; + + @override + String get restroomTypeMale => '男廁所'; + + @override + String get meshtasticLastReceived => '最近接收'; + + @override + String get reportDetailSortByCounty => '依縣市排序'; + + @override + String get onboardingPermUnusedApp => '保持 App 啟用'; + + @override + String get onboardingPermUnusedAppDesc => + 'Android 會暫停你長期未開啟嘅 App 並撤銷佢哋嘅權限,噉會令災害警報無法送到你所在地。'; + + @override + String get onboardingPermBackgroundExec => '背景執行'; + + @override + String get onboardingPermBackgroundExecDesc => '關閉時,App 不會被喚醒回報你的位置。'; + + @override + String get onboardingPermVendorPower => '手機廠商省電設定'; + + @override + String onboardingPermVendorPowerDesc(String brand) { + return '$brand 會停止你最近沒開過的 App 的背景作業。App 無法偵測或變更,請手動允許。'; + } + + @override + String get homeRainTrendScattered => '可能會有零星降雨'; + + @override + String get meshtasticUptime => '運行時間'; + + @override + String get weatherRankingTempExtremes => '溫度極值'; + + @override + String get themeLight => '淺色'; + + @override + String get mapTerrainReliefHint => '在底圖上顯示立體地形陰影'; + + @override + String get meshtasticEmptyMessage => '(空白訊息)'; + + @override + String get moreSectionRegion => '地區'; + + @override + String get mapLayerSatellite => 'ひまわり 紅外線(B13)'; + + @override + String get aedHoursSaturday => '週六開放時間'; + + @override + String get moonPhaseNew => '新月'; + + @override + String get notifySectionEew => '地震速報'; + + @override + String get mapResetNorth => '回到北方'; + + @override + String get rainInterval2d => '2 日'; + + @override + String get mapTownLabelsHint => '放大時顯示鄉鎮名稱'; + + @override + String get commonCancel => '取消'; + + @override + String get notifyOptTsunamiWarning => '只接收海嘯警報'; + + @override + String get mapLayerSatelliteBtdFog => 'ひまわり 夜間霧'; + + @override + String get moreSectionAdvanced => '進階'; + + @override + String get moreSectionMesh => 'Mesh 網絡'; + + @override + String get weatherRankingExtremeRange => '日溫差'; + + @override + String get permissionsTitle => '權限檢查'; + + @override + String get permissionsAttention => '權限需要處理'; + + @override + String get permissionsBody => 'DPIP 需要這些權限才能即時通知你。收不到警報時,通常就是其中一項尚未開啟。'; + + @override + String get notifySettingsMenu => '通知設定'; + + @override + String mapAppDefault(String app) { + return '$app(預設)'; + } + + @override + String get trendRange24h => '24 小時'; + + @override + String get mapLayerStyleJmaTooltip => '灰階為底,−40 °C 以下上色,凸顯雲頂高度'; + + @override + String get mapLayerRain => '雨量'; + + @override + String get mapLayerQpesums => '未來 1 小時降水預報'; + + @override + String get mapOverlaySectionMap => '地圖'; + + @override + String get mapTerrainRelief => '地形立體感'; + + @override + String get mapLegendCollapse => '收合圖例'; + + @override + String get updateAvailableTitle => '有新版本'; + + @override + String updateAvailableBody(String version) { return '新版本 $version 已發佈。'; } @@ -10459,162 +11039,452 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { String get tsunamiBand30cmTo1m => '0.3~1 公尺'; @override - String get tsunamiBandUnder30cm => '小於 0.3 公尺'; + String get tsunamiBandUnder30cm => '小於 0.3 公尺'; + + @override + String get tsunamiEmpty => '目前沒有海嘯警報'; + + @override + String tsunamiReportNumber(int n) { + return '第 $n 報'; + } + + @override + String tsunamiWaveHeightCm(int cm) { + return '$cm 公分'; + } + + @override + String get mapLayerLocationTrack => '軌跡'; + + @override + String get mapLayerLocationTrackSubtitle => '這部手機去過的地方,只存在本機'; + + @override + String get locationTrackRangeToday => '今日'; + + @override + String get locationTrackRangeWeek => '7 日'; + + @override + String get locationTrackRangeMonth => '30 日'; + + @override + String get locationTrackRangeAll => '全部'; + + @override + String locationTrackRangePoints(String count) { + return '$count 個點'; + } + + @override + String get locationTrackRangeEmpty => '這段期間沒有紀錄'; + + @override + String get locationTrackLocalOnly => + '只會儲存在這部手機上,不會上載。解除安裝 App 或清除 App 資料後就會消失。'; + + @override + String get locationTrackEmpty => '還沒有軌跡'; + + @override + String get locationTrackEmptyBody => '允許背景位置後,這部手機會自動記錄移動軌跡。'; + + @override + String get locationTrackStorage => '儲存空間'; + + @override + String locationTrackStored(String count, String size) { + return '已儲存 $count 個點 · $size'; + } + + @override + String locationTrackOldest(String date) { + return '最早一點:$date'; + } + + @override + String locationTrackLatest(String time) { + return '最新一點:$time'; + } + + @override + String get locationTrackCapacity => + '最多保留 50 MB,約可存 300 萬個點以上;以一般每日 100–300 個點估算,大約是數十年的紀錄。存滿後會先刪除最舊的點。'; + + @override + String locationTrackGapNote(int hours) { + return '相隔超過 $hours 小時的點不會連線。'; + } + + @override + String get locationTrackClear => '清除軌跡'; + + @override + String get locationTrackClearConfirm => '這部手機上記錄的所有軌跡都會刪除,而且無法復原。'; + + @override + String get locationTrackCleared => '已清除軌跡'; + + @override + String get locationTrackLegendNewer => '較新'; + + @override + String get locationTrackLegendOlder => '較舊'; + + @override + String get locationTrackLegendLatest => '最新位置'; + + @override + String get eewCancelled => '已取消'; + + @override + String get eewCancelledNotice => '發布單位已取消此速報'; + + @override + String get eewFeedDown => '地震速報連接中斷,重新連接中…'; + + @override + String get notifyPresetSection => '快速設定'; + + @override + String get notifyPresetRecommended => '建議'; + + @override + String get notifyPresetRecommendedDescription => + '所在地的地震、天氣及防災警訊,以及海嘯消息及公告;略過與震度速報重複的地震報告'; + + @override + String get notifyPresetAll => '全部'; + + @override + String get notifyPresetAllDescription => '每個頻道均接收全部,包括全台各地的地震'; + + @override + String get notifyPresetEmergencyOnly => '僅緊急'; + + @override + String get notifyPresetEmergencyOnlyDescription => + '只在攸關安全時通知:所在地震度 4 或以上的地震速報、所在地的天氣及防災警訊,以及海嘯警報'; + + @override + String get notifyPresetCustom => '自訂'; + + @override + String get notifyPresetCustomDescription => '在下方逐項調整各頻道'; + + @override + String notifyPresetConfirmTitle(String preset) { + return '套用「$preset」?'; + } + + @override + String get notifyPresetConfirmBody => '下方每個頻道都會改為這個組合,取代你逐項調整的設定。'; + + @override + String get notifyPresetApply => '套用'; + + @override + String get notifyPresetApplying => '正在套用…'; + + @override + String get onboardingNotifyTitle => '通知偏好'; + + @override + String get onboardingNotifyBody => '先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。'; + + @override + String get onboardingNotifySkip => '略過,之後再設定'; + + @override + String get aiChatTitle => 'AI 助理'; + + @override + String get aiChatNew => '新對話'; + + @override + String get aiChatAbout => '說明'; + + @override + String get aiChatInputHint => '問問地震、天氣或颱風…'; + + @override + String get aiChatSend => '傳送'; + + @override + String get aiChatStop => '停止'; + + @override + String get aiChatDisclaimer => 'AI 可能出錯,緊急時請以官方警報為準。'; + + @override + String get aiChatEmptyTitle => '想知道甚麼?'; + + @override + String get aiChatSuggestionQuakes => '最近有地震嗎?'; + + @override + String get aiChatSuggestionRain => '我這裡今天會下雨嗎?'; + + @override + String get aiChatSuggestionTyphoon => '現在有颱風嗎?'; + + @override + String get aiChatSuggestionAlerts => '目前有哪些災害警報?'; + + @override + String get aiChatThinking => '思考中…'; + + @override + String get aiChatWaiting => '等待回應…'; + + @override + String get aiChatStopped => '已停止'; + + @override + String get aiChatOpenSettings => '開啟設定'; + + @override + String get aiChatCopied => '已複製'; + + @override + String get aiChatSetupTitle => '仍未設定 AI 服務'; + + @override + String get aiChatSetupBody => '填入你自己的 AI 服務網址、金鑰和模型就可以開始。'; + + @override + String get aiChatSetupAction => '前往設定'; + + @override + String get aiIntroTitle => 'AI 助理'; + + @override + String get aiIntroBody => '問問最近的地震、明天的天氣或颱風動態。AI 會先即時查詢 DPIP 的資料,再用你的語言回答。'; + + @override + String get aiIntroOwnKeyTitle => '使用你自己的 AI 服務'; + + @override + String get aiIntroOwnKeyBody => + '填入任何兼容 OpenAI 格式、支援工具呼叫的 AI 服務網址和金鑰,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量和費用由你的 AI 服務計算。'; + + @override + String get aiIntroLocalTitle => '金鑰只會儲存在這部手機,對話不會儲存'; + + @override + String get aiIntroLocalBody => + 'API 金鑰儲存在系統的金鑰庫(iOS 鑰匙圈、Android Keystore),不會上載到 ExpTech,也不會隨備份還原到其他手機。對話不會被儲存,關閉 App 後就會消失。'; + + @override + String get aiIntroDataTitle => '資料怎樣傳送'; + + @override + String get aiIntroDataBody => + '你的問題會從手機直接傳送到你設定的 AI 服務,不經過 ExpTech。AI 需要資料時,App 會向 DPIP 的公開資料伺服器查詢,只傳送查詢條件(例如地區代碼或日期),不包含金鑰和對話。為了知道「我這裡」是哪裡,你在 App 中的所在地和收藏的鄉鎮(只有名稱和代碼)會一併告訴 AI。'; + + @override + String get aiIntroCautionTitle => 'AI 可能出錯'; + + @override + String get aiIntroCautionBody => + 'AI 的回答僅供參考。可能有危險時,請先保護自己,並以中央氣象署和政府的官方警報與指示為準。'; + + @override + String get aiIntroMcpTitle => '自己動手做'; + + @override + String get aiIntroMcpBody => + 'DPIP 的資料亦以 MCP 伺服器形式開放,唯讀、不需要金鑰。可以接駁到 Claude、ChatGPT 或其他支援 MCP 的工具,做出你自己的應用。'; + + @override + String get aiIntroStart => '開始設定'; + + @override + String get aiMcpTitle => 'DPIP MCP 伺服器'; + + @override + String get aiMcpBody => '支援 Streamable HTTP 的 MCP 用戶端都可以直接使用這個網址。'; + + @override + String get aiMcpCopyUrl => '複製網址'; + + @override + String get aiMcpCopyConfig => '複製設定範例'; + + @override + String get aiMcpCopied => '已複製'; + + @override + String get aiSettingsTitle => 'AI 設定'; + + @override + String get aiSettingsService => 'AI 服務'; + + @override + String get aiSettingsServiceBody => + '任何兼容 OpenAI 格式(/chat/completions)並支援工具呼叫的服務都可以。點選下方的服務會自動填入網址。'; + + @override + String get aiSettingsBaseUrl => 'API 網址'; + + @override + String get aiSettingsBaseUrlHelper => 'API 的根網址,通常以 /v1 結尾。'; + + @override + String get aiSettingsUrlEmpty => '請輸入網址'; + + @override + String get aiSettingsUrlInvalid => '這不是有效的網址'; + + @override + String get aiSettingsUrlInsecure => + '請使用 https://。只有連接這部手機本身(localhost)時才可以用 http://。'; @override - String get tsunamiEmpty => '目前沒有海嘯警報'; + String get aiSettingsApiKey => 'API 金鑰'; @override - String tsunamiReportNumber(int n) { - return '第 $n 報'; - } + String get aiSettingsApiKeyHelper => '只會儲存在這部手機的系統金鑰庫。不需要金鑰的服務可以留空。'; @override - String tsunamiWaveHeightCm(int cm) { - return '$cm 公分'; - } + String get aiSettingsApiKeySaved => '已儲存金鑰。留空會保留原本的金鑰。'; @override - String get mapLayerLocationTrack => '軌跡'; + String get aiSettingsShowKey => '顯示金鑰'; @override - String get mapLayerLocationTrackSubtitle => '這部手機去過的地方,只存在本機'; + String get aiSettingsHideKey => '隱藏金鑰'; @override - String get locationTrackRangeToday => '今日'; + String get aiSettingsRemoveKey => '刪除已儲存的金鑰'; @override - String get locationTrackRangeWeek => '7 日'; + String get aiSettingsKeyRemoved => '已刪除金鑰'; @override - String get locationTrackRangeMonth => '30 日'; + String get aiSettingsModel => '模型'; @override - String get locationTrackRangeAll => '全部'; + String get aiSettingsModelHelper => + '需要支援工具呼叫(function calling)的模型。點右邊的按鈕可以取得服務的模型清單。'; @override - String locationTrackRangePoints(String count) { - return '$count 個點'; - } + String get aiSettingsModelEmpty => '請輸入或選擇模型'; @override - String get locationTrackRangeEmpty => '這段期間沒有紀錄'; + String get aiSettingsFetchModels => '取得模型清單'; @override - String get locationTrackLocalOnly => - '只會儲存在這部手機上,不會上載。解除安裝 App 或清除 App 資料後就會消失。'; + String get aiSettingsFilterModels => '搜尋模型'; @override - String get locationTrackEmpty => '還沒有軌跡'; + String get aiSettingsNoModels => '這個服務沒有列出任何模型,請直接輸入名稱。'; @override - String get locationTrackEmptyBody => '允許背景位置後,這部手機會自動記錄移動軌跡。'; + String get aiSettingsSave => '儲存'; @override - String get locationTrackStorage => '儲存空間'; + String get aiSettingsSaved => '已儲存'; @override - String locationTrackStored(String count, String size) { - return '已儲存 $count 個點 · $size'; - } + String get aiSettingsSaveFailed => '無法儲存金鑰,請再試一次。'; @override - String locationTrackOldest(String date) { - return '最早一點:$date'; - } + String get aiSettingsPrivacy => '私隱'; @override - String locationTrackLatest(String time) { - return '最新一點:$time'; - } + String get aiSettingsPrivacyBody => + '金鑰只會儲存在這部手機的系統金鑰庫,不會上載到 ExpTech。對話只在手機和你的 AI 服務之間傳送,不會被儲存。AI 查詢資料時,只有查詢條件會傳送到 DPIP 的資料伺服器。'; @override - String get locationTrackCapacity => - '最多保留 50 MB,約可存 300 萬個點以上;以一般每日 100–300 個點估算,大約是數十年的紀錄。存滿後會先刪除最舊的點。'; + String get aiSettingsForget => '清除所有 AI 設定'; @override - String locationTrackGapNote(int hours) { - return '相隔超過 $hours 小時的點不會連線。'; - } + String get aiSettingsForgetTitle => '清除 AI 設定?'; @override - String get locationTrackClear => '清除軌跡'; + String get aiSettingsForgetBody => '會從這部手機刪除網址、模型和金鑰。'; @override - String get locationTrackClearConfirm => '這部手機上記錄的所有軌跡都會刪除,而且無法復原。'; + String get aiSettingsForgetConfirm => '清除'; @override - String get locationTrackCleared => '已清除軌跡'; + String get aiSettingsForgotten => '已清除 AI 設定'; @override - String get locationTrackLegendNewer => '較新'; + String get aiToolEarthquake => '地震資料'; @override - String get locationTrackLegendOlder => '較舊'; + String get aiToolWeather => '天氣資料'; @override - String get locationTrackLegendLatest => '最新位置'; + String get aiToolTyphoon => '颱風資料'; @override - String get eewCancelled => '已取消'; + String get aiToolTsunami => '海嘯資料'; @override - String get eewCancelledNotice => '發布單位已取消此速報'; + String get aiToolNotices => '災害通知'; @override - String get eewFeedDown => '地震速報連接中斷,重新連接中…'; + String get aiToolPlaces => '地點'; @override - String get notifyPresetSection => '快速設定'; + String get aiToolTimeline => '資料時間'; @override - String get notifyPresetRecommended => '建議'; + String get aiToolOther => '資料'; @override - String get notifyPresetRecommendedDescription => - '所在地的地震、天氣及防災警訊,以及海嘯消息及公告;略過與震度速報重複的地震報告'; + String aiToolRunning(String subject) { + return '正在查詢$subject…'; + } @override - String get notifyPresetAll => '全部'; + String aiToolDone(String subject) { + return '已查詢$subject'; + } @override - String get notifyPresetAllDescription => '每個頻道均接收全部,包括全台各地的地震'; + String aiToolFailed(String subject) { + return '$subject查詢失敗'; + } @override - String get notifyPresetEmergencyOnly => '僅緊急'; + String get aiErrorUnauthorized => 'API 金鑰無效、過期,或沒有使用這個模型的權限。'; @override - String get notifyPresetEmergencyOnlyDescription => - '只在攸關安全時通知:所在地震度 4 或以上的地震速報、所在地的天氣及防災警訊,以及海嘯警報'; + String get aiErrorNotFound => '找不到 API 或模型,請檢查網址和模型名稱。'; @override - String get notifyPresetCustom => '自訂'; + String get aiErrorRateLimited => '已達到 AI 服務的速率限制或額度上限,請稍後再試或檢查計劃。'; @override - String get notifyPresetCustomDescription => '在下方逐項調整各頻道'; + String get aiErrorRejected => 'AI 服務拒絕了這個請求,所選的模型可能不支援工具呼叫。'; @override - String notifyPresetConfirmTitle(String preset) { - return '套用「$preset」?'; - } + String get aiErrorServer => 'AI 服務暫時出錯,請稍後再試。'; @override - String get notifyPresetConfirmBody => '下方每個頻道都會改為這個組合,取代你逐項調整的設定。'; + String get aiErrorNetwork => '無法連接 AI 服務,請檢查網絡。'; @override - String get notifyPresetApply => '套用'; + String get aiErrorTimeout => 'AI 服務太久沒有回應。'; @override - String get notifyPresetApplying => '正在套用…'; + String get aiErrorBadResponse => '無法辨識 AI 服務的回應,這個網址可能不是兼容 OpenAI 格式的 API。'; @override - String get onboardingNotifyTitle => '通知偏好'; + String get aiErrorToolsUnavailable => '暫時無法連接 DPIP 資料伺服器,請稍後再試。'; @override - String get onboardingNotifyBody => '先選一個起點,之後隨時可以在「通知設定」中逐項調整。每個選項都會保留地震速報。'; + String get aiErrorTooManySteps => 'AI 查詢了太多次資料仍未有結論,請把問題問得更具體。'; @override - String get onboardingNotifySkip => '略過,之後再設定'; + String aiErrorServiceSaid(String detail) { + return '服務的訊息:$detail'; + } } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -14152,4 +15022,294 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get onboardingNotifySkip => '略過,之後再設定'; + + @override + String get aiChatTitle => 'AI 助理'; + + @override + String get aiChatNew => '新對話'; + + @override + String get aiChatAbout => '說明'; + + @override + String get aiChatInputHint => '問問地震、天氣或颱風…'; + + @override + String get aiChatSend => '傳送'; + + @override + String get aiChatStop => '停止'; + + @override + String get aiChatDisclaimer => 'AI 可能出錯,緊急時請以官方警報為準。'; + + @override + String get aiChatEmptyTitle => '想知道什麼?'; + + @override + String get aiChatSuggestionQuakes => '最近有地震嗎?'; + + @override + String get aiChatSuggestionRain => '我這裡今天會下雨嗎?'; + + @override + String get aiChatSuggestionTyphoon => '現在有颱風嗎?'; + + @override + String get aiChatSuggestionAlerts => '目前有哪些災害警報?'; + + @override + String get aiChatThinking => '思考中…'; + + @override + String get aiChatWaiting => '等待回應…'; + + @override + String get aiChatStopped => '已停止'; + + @override + String get aiChatOpenSettings => '開啟設定'; + + @override + String get aiChatCopied => '已複製'; + + @override + String get aiChatSetupTitle => '還沒設定 AI 服務'; + + @override + String get aiChatSetupBody => '填入你自己的 AI 服務網址、金鑰和模型就可以開始。'; + + @override + String get aiChatSetupAction => '前往設定'; + + @override + String get aiIntroTitle => 'AI 助理'; + + @override + String get aiIntroBody => '問問最近的地震、明天的天氣或颱風動態。AI 會先即時查詢 DPIP 的資料,再用你的語言回答。'; + + @override + String get aiIntroOwnKeyTitle => '使用你自己的 AI 服務'; + + @override + String get aiIntroOwnKeyBody => + '填入任何相容 OpenAI 格式、支援工具呼叫的 AI 服務網址和金鑰,例如 OpenAI、Google Gemini、Anthropic、OpenRouter。用量和費用由你的 AI 服務計算。'; + + @override + String get aiIntroLocalTitle => '金鑰只存在這支手機,對話不會儲存'; + + @override + String get aiIntroLocalBody => + 'API 金鑰存在系統的金鑰庫(iOS 鑰匙圈、Android Keystore),不會上傳到 ExpTech,也不會隨備份還原到其他手機。對話不會被儲存,關掉 App 就會消失。'; + + @override + String get aiIntroDataTitle => '資料怎麼傳送'; + + @override + String get aiIntroDataBody => + '你的問題會從手機直接送到你設定的 AI 服務,不經過 ExpTech。AI 需要資料時,App 會向 DPIP 的公開資料伺服器查詢,只送出查詢條件(例如地區代碼或日期),不含金鑰和對話。為了知道「我這裡」是哪裡,你在 App 裡的所在地和收藏的鄉鎮(只有名稱和代碼)會一起告訴 AI。'; + + @override + String get aiIntroCautionTitle => 'AI 可能出錯'; + + @override + String get aiIntroCautionBody => + 'AI 的回答僅供參考。可能有危險時,請先保護自己,並以中央氣象署和政府的官方警報與指示為準。'; + + @override + String get aiIntroMcpTitle => '自己動手做'; + + @override + String get aiIntroMcpBody => + 'DPIP 的資料也開放成 MCP 伺服器,唯讀、不需要金鑰。可以接到 Claude、ChatGPT 或其他支援 MCP 的工具,做出你自己的應用。'; + + @override + String get aiIntroStart => '開始設定'; + + @override + String get aiMcpTitle => 'DPIP MCP 伺服器'; + + @override + String get aiMcpBody => '支援 Streamable HTTP 的 MCP 用戶端都可以直接使用這個網址。'; + + @override + String get aiMcpCopyUrl => '複製網址'; + + @override + String get aiMcpCopyConfig => '複製設定範例'; + + @override + String get aiMcpCopied => '已複製'; + + @override + String get aiSettingsTitle => 'AI 設定'; + + @override + String get aiSettingsService => 'AI 服務'; + + @override + String get aiSettingsServiceBody => + '任何相容 OpenAI 格式(/chat/completions)並支援工具呼叫的服務都可以。點選下方的服務會自動填入網址。'; + + @override + String get aiSettingsBaseUrl => 'API 網址'; + + @override + String get aiSettingsBaseUrlHelper => 'API 的根網址,通常以 /v1 結尾。'; + + @override + String get aiSettingsUrlEmpty => '請輸入網址'; + + @override + String get aiSettingsUrlInvalid => '這不是有效的網址'; + + @override + String get aiSettingsUrlInsecure => + '請使用 https://。只有連到這支手機本身(localhost)才可以用 http://。'; + + @override + String get aiSettingsApiKey => 'API 金鑰'; + + @override + String get aiSettingsApiKeyHelper => '只存在這支手機的系統金鑰庫。不需要金鑰的服務可以留空。'; + + @override + String get aiSettingsApiKeySaved => '已儲存金鑰。留空會保留原本的金鑰。'; + + @override + String get aiSettingsShowKey => '顯示金鑰'; + + @override + String get aiSettingsHideKey => '隱藏金鑰'; + + @override + String get aiSettingsRemoveKey => '刪除已儲存的金鑰'; + + @override + String get aiSettingsKeyRemoved => '已刪除金鑰'; + + @override + String get aiSettingsModel => '模型'; + + @override + String get aiSettingsModelHelper => + '需要支援工具呼叫(function calling)的模型。點右邊的按鈕可以取得服務的模型清單。'; + + @override + String get aiSettingsModelEmpty => '請輸入或選擇模型'; + + @override + String get aiSettingsFetchModels => '取得模型清單'; + + @override + String get aiSettingsFilterModels => '搜尋模型'; + + @override + String get aiSettingsNoModels => '這個服務沒有列出任何模型,請直接輸入名稱。'; + + @override + String get aiSettingsSave => '儲存'; + + @override + String get aiSettingsSaved => '已儲存'; + + @override + String get aiSettingsSaveFailed => '無法儲存金鑰,請再試一次。'; + + @override + String get aiSettingsPrivacy => '隱私'; + + @override + String get aiSettingsPrivacyBody => + '金鑰只存在這支手機的系統金鑰庫,不會上傳到 ExpTech。對話只在手機和你的 AI 服務之間傳送,不會被儲存。AI 查資料時,只有查詢條件會送到 DPIP 的資料伺服器。'; + + @override + String get aiSettingsForget => '清除所有 AI 設定'; + + @override + String get aiSettingsForgetTitle => '清除 AI 設定?'; + + @override + String get aiSettingsForgetBody => '會從這支手機刪除網址、模型和金鑰。'; + + @override + String get aiSettingsForgetConfirm => '清除'; + + @override + String get aiSettingsForgotten => '已清除 AI 設定'; + + @override + String get aiToolEarthquake => '地震資料'; + + @override + String get aiToolWeather => '天氣資料'; + + @override + String get aiToolTyphoon => '颱風資料'; + + @override + String get aiToolTsunami => '海嘯資料'; + + @override + String get aiToolNotices => '災害通知'; + + @override + String get aiToolPlaces => '地點'; + + @override + String get aiToolTimeline => '資料時間'; + + @override + String get aiToolOther => '資料'; + + @override + String aiToolRunning(String subject) { + return '正在查詢$subject…'; + } + + @override + String aiToolDone(String subject) { + return '已查詢$subject'; + } + + @override + String aiToolFailed(String subject) { + return '$subject查詢失敗'; + } + + @override + String get aiErrorUnauthorized => 'API 金鑰無效、過期,或沒有使用這個模型的權限。'; + + @override + String get aiErrorNotFound => '找不到 API 或模型,請檢查網址和模型名稱。'; + + @override + String get aiErrorRateLimited => '已達到 AI 服務的速率限制或額度上限,請稍後再試或檢查方案。'; + + @override + String get aiErrorRejected => 'AI 服務拒絕了這個請求,選擇的模型可能不支援工具呼叫。'; + + @override + String get aiErrorServer => 'AI 服務暫時出錯,請稍後再試。'; + + @override + String get aiErrorNetwork => '無法連上 AI 服務,請檢查網路。'; + + @override + String get aiErrorTimeout => 'AI 服務太久沒有回應。'; + + @override + String get aiErrorBadResponse => '無法辨識 AI 服務的回應,這個網址可能不是相容 OpenAI 格式的 API。'; + + @override + String get aiErrorToolsUnavailable => '暫時無法連上 DPIP 資料伺服器,請稍後再試。'; + + @override + String get aiErrorTooManySteps => 'AI 查了太多次資料還沒有結論,請把問題問得更具體。'; + + @override + String aiErrorServiceSaid(String detail) { + return '服務的訊息:$detail'; + } } diff --git a/lib/shared/navigation/app_routes.dart b/lib/shared/navigation/app_routes.dart index 38776f913..855c48296 100644 --- a/lib/shared/navigation/app_routes.dart +++ b/lib/shared/navigation/app_routes.dart @@ -79,6 +79,14 @@ abstract final class AppRoutes { static const String more = 'more'; static const String morePath = '/more'; + /// The AI assistant, pushed over the shell from Home's region bar. + static const String ai = 'ai'; + static const String aiPath = '/ai'; + + /// The AI chat's service setup (URL, key, model), pushed over the shell. + static const String aiSettings = 'aiSettings'; + static const String aiSettingsPath = '/ai-settings'; + // First-launch onboarding (intro → terms → permissions), shown before the // shell until completed. static const String onboarding = 'onboarding'; diff --git a/lib/shared/widgets/region_bar.dart b/lib/shared/widgets/region_bar.dart index 031b96d20..d92132e91 100644 --- a/lib/shared/widgets/region_bar.dart +++ b/lib/shared/widgets/region_bar.dart @@ -21,12 +21,16 @@ import 'package:provider/provider.dart'; /// feeds these from its sheet extent; Events leaves them at 0 for an opaque /// bar. It carries its own top safe area so both effects cover the status-bar /// row too. +/// +/// A page may put one action at the bar's right end ([trailing]); it fades and +/// slides away with the bar, and is inked like the badges. class RegionBar extends StatefulWidget { const RegionBar({ super.key, this.blend = 0, this.dismiss = 0, this.skyIsLight = true, + this.trailing, }); /// The bar's own height, below the status bar it is inset by. Public because @@ -44,6 +48,14 @@ class RegionBar extends StatefulWidget { /// a light sky (dark-theme white [ColorScheme.onSurface] would vanish). final bool skyIsLight; + /// One action at the right end — an icon button. The carousel keeps an + /// equal inset on both sides while it is there, so the selected badge stays + /// centred on the screen rather than shifting left by half the button. + final Widget? trailing; + + /// The width kept for [trailing], on each side of the carousel. + static const double actionWidth = 44; + @override State createState() => _RegionBarState(); } @@ -78,31 +90,34 @@ class _RegionBarState extends State with AreaPageSyncMixin { bottom: false, child: SizedBox( height: RegionBar.height, - child: PageView.builder( - controller: areaPageController, - physics: const NeverScrollableScrollPhysics(), - itemCount: areas.length, - itemBuilder: (context, index) { - // Resolved here, once per item, not inside the builder - // below: the label is a directory lookup that does not - // depend on the page position, and the builder runs for - // every visible badge on every pixel of the slide. - final label = regionAreaLabel( - l10n, - directory, - areas[index], - ); - return AnimatedBuilder( - animation: areaPageController, - builder: (context, _) => _RegionBadge( - index: index, - label: label, - distance: (areaPage - index).abs(), - blend: blend, - skyIsLight: widget.skyIsLight, - ), - ); - }, + child: _withTrailing( + context, + PageView.builder( + controller: areaPageController, + physics: const NeverScrollableScrollPhysics(), + itemCount: areas.length, + itemBuilder: (context, index) { + // Resolved here, once per item, not inside the builder + // below: the label is a directory lookup that does not + // depend on the page position, and the builder runs for + // every visible badge on every pixel of the slide. + final label = regionAreaLabel( + l10n, + directory, + areas[index], + ); + return AnimatedBuilder( + animation: areaPageController, + builder: (context, _) => _RegionBadge( + index: index, + label: label, + distance: (areaPage - index).abs(), + blend: blend, + skyIsLight: widget.skyIsLight, + ), + ); + }, + ), ), ), ), @@ -111,6 +126,40 @@ class _RegionBarState extends State with AreaPageSyncMixin { ), ); } + + /// [carousel] alone, or with [RegionBar.trailing] at the right end. + Widget _withTrailing(BuildContext context, Widget carousel) { + final trailing = widget.trailing; + if (trailing == null) return carousel; + // The same sky-aware ink as the unselected badges, for an icon button. + final ink = inkOverWeather( + Theme.of(context).colorScheme, + widget.blend, + skyIsLight: widget.skyIsLight, + ); + return Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: RegionBar.actionWidth, + ), + child: carousel, + ), + Positioned( + top: 0, + right: 0, + bottom: 0, + width: RegionBar.actionWidth, + child: IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom(foregroundColor: ink), + ), + child: Center(child: trailing), + ), + ), + ], + ); + } } /// A single area badge, styled by its [distance] from the carousel centre. diff --git a/test/app/router/app_router_test.dart b/test/app/router/app_router_test.dart index 014f7ef2e..b7e53cba0 100644 --- a/test/app/router/app_router_test.dart +++ b/test/app/router/app_router_test.dart @@ -168,6 +168,20 @@ void main() { ); }); + test('the AI assistant is a page over the shell, not a sixth tab', () { + final routes = appRouter.configuration.routes; + final shell = routes.whereType().single; + // Material's bottom bar is for three to five destinations. + expect(shell.branches, hasLength(5)); + final branchNames = [ + for (final branch in shell.branches) + for (final route in branch.routes.whereType()) route.name, + ]; + expect(branchNames, isNot(contains(AppRoutes.ai))); + final ai = routes.whereType().where((r) => r.name == AppRoutes.ai); + expect(ai.single.path, AppRoutes.aiPath); + }); + testWidgets('an unfinished welcome stays put until the refresh fires', ( tester, ) async { diff --git a/test/features/ai_chat/data/ai_http_test.dart b/test/features/ai_chat/data/ai_http_test.dart new file mode 100644 index 000000000..e90606415 --- /dev/null +++ b/test/features/ai_chat/data/ai_http_test.dart @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; +import 'package:dpip/features/ai_chat/data/ai_http.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('statuses map to what the page explains', () { + AiFailureKind kind(int status) => aiExceptionForStatus(status, '').kind; + expect(kind(401), AiFailureKind.unauthorized); + expect(kind(403), AiFailureKind.unauthorized); + expect(kind(404), AiFailureKind.notFound); + expect(kind(429), AiFailureKind.rateLimited); + expect(kind(400), AiFailureKind.rejected); + expect(kind(422), AiFailureKind.rejected); + expect(kind(503), AiFailureKind.server); + }); + + test('the reason is read from the common error shapes', () { + expect(errorDetail('{"error":{"message":"bad key"}}'), 'bad key'); + expect(errorDetail('{"error":"model not found"}'), 'model not found'); + expect(errorDetail('{"message":"slow down"}'), 'slow down'); + expect(errorDetail('{"detail":"nope"}'), 'nope'); + expect(errorDetail('[{"error":{"message":"gemini says"}}]'), 'gemini says'); + expect(errorDetail('plain words'), 'plain words'); + expect(errorDetail('gateway'), isNull); + expect(errorDetail(''), isNull); + expect(errorDetail('x' * 400), hasLength(301)); + }); + + test('a cancellation is not a failure', () { + expect( + aiExceptionForDio( + DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.cancel, + ), + ), + isNull, + ); + expect( + aiExceptionForDio( + DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.receiveTimeout, + ), + )?.kind, + AiFailureKind.timeout, + ); + }); +} diff --git a/test/features/ai_chat/data/ai_settings_repository_impl_test.dart b/test/features/ai_chat/data/ai_settings_repository_impl_test.dart new file mode 100644 index 000000000..54b577363 --- /dev/null +++ b/test/features/ai_chat/data/ai_settings_repository_impl_test.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; + +import 'package:dpip/core/platform/secret_store.dart'; +import 'package:dpip/core/settings/setting_keys.dart'; +import 'package:dpip/core/settings/settings_store.dart'; +import 'package:dpip/features/ai_chat/data/ai_settings_repository_impl.dart'; +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fakes.dart'; + +void main() { + late SettingsStore settings; + late FakeSecretStore secrets; + late FakeApi api; + late AiSettingsRepositoryImpl repository; + + setUp(() { + settings = SettingsStore.inMemory(); + secrets = FakeSecretStore(); + api = FakeApi(); + repository = AiSettingsRepositoryImpl(settings, secrets, api); + }); + + const connection = AiConnection( + baseUrl: 'https://ai.example/v1', + model: 'm-1', + ); + + test('the key goes to key storage, never to settings', () async { + await repository.save(connection, apiKey: ' sk-secret '); + expect(secrets.values[SecretKey.aiApiKey], 'sk-secret'); + expect(settings.getString(SettingKeys.aiBaseUrl), connection.baseUrl); + expect(settings.getString(SettingKeys.aiModel), connection.model); + // Nothing in settings holds the key. + for (final key in [SettingKeys.aiBaseUrl, SettingKeys.aiModel]) { + expect(settings.getString(key), isNot(contains('sk-secret'))); + } + expect(await repository.apiKey(), 'sk-secret'); + }); + + test('no key keeps the saved one; an empty key removes it', () async { + await repository.save(connection, apiKey: 'sk-1'); + await repository.save(connection); + expect(await repository.apiKey(), 'sk-1'); + await repository.save(connection, apiKey: ''); + expect(await repository.apiKey(), isNull); + }); + + test('a refused key changes nothing else', () async { + secrets.refuseWrites = true; + await expectLater( + repository.save(connection, apiKey: 'k'), + throwsStateError, + ); + expect(repository.connection.baseUrl, isEmpty); + }); + + test('forget removes the URL, the model and the key', () async { + await repository.save(connection, apiKey: 'sk-1'); + await repository.forget(); + expect(repository.connection.isComplete, isFalse); + expect(await repository.apiKey(), isNull); + }); + + test('the intro is shown until it is marked seen', () async { + expect(repository.introSeen, isFalse); + await repository.markIntroSeen(); + expect(repository.introSeen, isTrue); + }); + + group('listing models', () { + test('reads ids, sorted and once each, with the key', () async { + api.onStream = (_) async => streamed( + 200, + Stream.value( + utf8.encode( + jsonEncode({ + 'data': [ + {'id': 'b'}, + {'id': 'a'}, + {'id': 'b'}, + {'object': 'no id'}, + ], + }), + ), + ), + contentType: 'application/json', + ); + final ids = await repository.listModels('https://ai.example/v1', 'k'); + expect(ids, ['a', 'b']); + final request = api.seen.single; + expect(request.method, 'GET'); + expect(request.url, 'https://ai.example/v1/models'); + expect(request.headers['Authorization'], 'Bearer k'); + }); + + test('a 401 is reported as an unauthorized key', () async { + api.onStream = (_) async => streamed( + 401, + Stream.value(utf8.encode('{"error":{"message":"bad key"}}')), + contentType: 'application/json', + ); + await expectLater( + repository.listModels('https://ai.example/v1', 'k'), + throwsA( + isA() + .having((e) => e.kind, 'kind', AiFailureKind.unauthorized) + .having((e) => e.detail, 'detail', 'bad key'), + ), + ); + }); + + test('an answer that is not a model list is a bad response', () async { + api.onStream = (_) async => + streamed(200, Stream.value(utf8.encode(''))); + await expectLater( + repository.listModels('https://ai.example/v1', null), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + AiFailureKind.badResponse, + ), + ), + ); + }); + }); +} diff --git a/test/features/ai_chat/data/dpip_mcp_tool_host_test.dart b/test/features/ai_chat/data/dpip_mcp_tool_host_test.dart new file mode 100644 index 000000000..21f26d92c --- /dev/null +++ b/test/features/ai_chat/data/dpip_mcp_tool_host_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:dpip/core/network/api_paths.dart'; +import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/features/ai_chat/data/dpip_mcp_tool_host.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fakes.dart'; + +/// A JSON-RPC answer as the server sends it: one SSE `message` event. +Response _sse(Object message, {Map>? headers}) => + Response( + requestOptions: RequestOptions(), + statusCode: 200, + headers: Headers.fromMap({ + Headers.contentTypeHeader: ['text/event-stream'], + ...?headers, + }), + data: 'event: message\ndata: ${jsonEncode(message)}\n\n', + ); + +Response _json(Object message) => Response( + requestOptions: RequestOptions(), + statusCode: 200, + headers: Headers.fromMap({ + Headers.contentTypeHeader: ['application/json'], + }), + data: jsonEncode(message), +); + +void main() { + late FakeApi api; + late List tiers; + late Map params)> results; + + setUp(() { + api = FakeApi(); + tiers = []; + results = { + 'initialize': (_) => {'instructions': 'Times are Unix seconds.'}, + 'tools/list': (_) => { + 'tools': [ + { + 'name': 'eew', + 'description': 'warnings', + 'inputSchema': {'type': 'object'}, + }, + {'description': 'nameless, skipped'}, + ], + }, + }; + api.onRequest = (tier, request) async { + tiers.add(tier); + final body = request.json; + final method = body['method'] as String; + if (!body.containsKey('id')) { + return Response(requestOptions: RequestOptions(), statusCode: 202); + } + final params = (body['params'] as Map?)?.cast() ?? {}; + final answer = results[method]!(params); + return _sse({ + 'jsonrpc': '2.0', + 'id': body['id'], + if (answer is Map && answer.containsKey('error')) + 'error': answer['error'] + else + 'result': answer, + }); + }; + }); + + List methods() => [ + for (final r in api.seen) r.json['method'] as String, + ]; + + test('lists tools once, after initializing, on the core tier', () async { + final host = DpipMcpToolHost(api); + final catalog = await host.catalog(); + await host.catalog(); + + expect(methods(), [ + 'initialize', + 'notifications/initialized', + 'tools/list', + ]); + expect(tiers.toSet(), {ApiTier.coreApi}); + expect(api.seen.map((r) => r.url).toSet(), {ApiPaths.mcp}); + final headers = api.seen.first.headers; + expect(headers['Accept'], 'application/json, text/event-stream'); + expect(headers['MCP-Protocol-Version'], DpipMcpToolHost.protocolVersion); + + expect(catalog.tools.map((t) => t.name), ['eew']); + expect(catalog.instructions, 'Times are Unix seconds.'); + }); + + test('follows the cursor across pages', () async { + results['tools/list'] = (params) => params['cursor'] == null + ? { + 'tools': [ + {'name': 'a', 'inputSchema': {}}, + ], + 'nextCursor': 'p2', + } + : { + 'tools': [ + {'name': 'b', 'inputSchema': {}}, + ], + }; + final catalog = await DpipMcpToolHost(api).catalog(); + expect(catalog.tools.map((t) => t.name), ['a', 'b']); + }); + + test('a tool answers with its structured content', () async { + results['tools/call'] = (params) => { + 'content': [ + {'type': 'text', 'text': 'ignored when structured content exists'}, + ], + 'structuredContent': {'data': [], 'name': params['name']}, + }; + final outcome = await DpipMcpToolHost(api).call('eew', {'at': 1}); + expect(outcome.isError, isFalse); + expect(jsonDecode(outcome.content), {'data': [], 'name': 'eew'}); + expect(api.seen.single.json['params'], { + 'name': 'eew', + 'arguments': {'at': 1}, + }); + }); + + test('a tool error and an RPC error both come back to the model', () async { + final host = DpipMcpToolHost(api); + results['tools/call'] = (_) => { + 'isError': true, + 'content': [ + {'type': 'text', 'text': 'bad station'}, + ], + }; + final toolError = await host.call('eew', {}); + expect(toolError.isError, isTrue); + expect(toolError.content, 'bad station'); + + results['tools/call'] = (_) => { + 'error': {'code': -32602, 'message': 'Invalid params'}, + }; + final rpcError = await host.call('eew', {}); + expect(rpcError.isError, isTrue); + expect(rpcError.content, 'Invalid params'); + }); + + test('a plain JSON answer is read too', () async { + api.onRequest = (tier, request) async => _json({ + 'jsonrpc': '2.0', + 'id': request.json['id'], + 'result': { + 'content': [ + {'type': 'text', 'text': 'one'}, + {'type': 'text', 'text': 'two'}, + ], + }, + }); + final outcome = await DpipMcpToolHost(api).call('eew', {}); + expect(outcome.content, 'one\ntwo'); + }); + + test('an unreachable server fails the listing, not a single call', () async { + api.onRequest = (_, _) async => throw DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.connectionError, + ); + final host = DpipMcpToolHost(api); + await expectLater( + host.catalog(), + throwsA( + isA().having( + (e) => e.kind, + 'kind', + AiFailureKind.toolsUnavailable, + ), + ), + ); + final outcome = await host.call('eew', {}); + expect(outcome.isError, isTrue); + }); + + test('a session id the server hands out is sent back', () async { + final base = api.onRequest!; + api.onRequest = (tier, request) async { + final response = await base(tier, request); + if (request.json['method'] == 'initialize') { + return _sse( + jsonDecode((response.data as String).split('data: ').last.trim()) + as Object, + headers: { + 'mcp-session-id': ['s-1'], + }, + ); + } + return response; + }; + await DpipMcpToolHost(api).catalog(); + expect(api.seen.first.headers.containsKey('Mcp-Session-Id'), isFalse); + expect(api.seen.last.headers['Mcp-Session-Id'], 's-1'); + }); +} diff --git a/test/features/ai_chat/data/openai_chat_model_test.dart b/test/features/ai_chat/data/openai_chat_model_test.dart new file mode 100644 index 000000000..260514b0a --- /dev/null +++ b/test/features/ai_chat/data/openai_chat_model_test.dart @@ -0,0 +1,325 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:dpip/features/ai_chat/data/openai_chat_model.dart'; +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/chat_model.dart'; +import 'package:dpip/features/ai_chat/domain/tool_host.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fakes.dart'; + +const _connection = AiConnection( + baseUrl: 'https://ai.example/v1', + model: 'm-1', +); + +const _tool = ToolSpec( + name: 'eew', + description: 'warnings', + parameters: { + 'type': 'object', + 'properties': { + 'at': { + 'type': ['null', 'integer'], + }, + }, + 'additionalProperties': false, + }, +); + +void main() { + late FakeApi api; + + setUp(() => api = FakeApi()); + + Future> complete({String? key = 'sk-1'}) => + OpenAiChatModel(api, _connection, key) + .complete(messages: const [UserMessage('hi')], tools: const [_tool]) + .toList(); + + group('the request', () { + test('posts the conversation and tools, streamed, with the key', () async { + api.onStream = (_) async => streamed(200, sse([textChunk('ok')])); + await complete(); + final request = api.seen.single; + expect(request.method, 'POST'); + expect(request.url, 'https://ai.example/v1/chat/completions'); + expect(request.headers['Authorization'], 'Bearer sk-1'); + final body = request.json; + expect(body['model'], 'm-1'); + expect(body['stream'], isTrue); + expect(body['messages'], [ + {'role': 'user', 'content': 'hi'}, + ]); + final tools = body['tools']! as List; + final function = (tools.single as Map)['function'] as Map; + expect(function['name'], 'eew'); + // Simplified for the pickier services. + expect(function['parameters'], { + 'type': 'object', + 'properties': { + 'at': {'type': 'integer'}, + }, + }); + }); + + test('sends no Authorization header without a key', () async { + api.onStream = (_) async => streamed(200, sse([textChunk('ok')])); + await complete(key: null); + expect(api.seen.single.headers.containsKey('Authorization'), isFalse); + }); + }); + + group('the answer', () { + test('streams text and ends with the whole message', () async { + api.onStream = (_) async => + streamed(200, sse([textChunk('目前'), textChunk('沒有')])); + final events = await complete(); + expect(events.whereType().map((e) => e.delta), ['目前', '沒有']); + final done = events.last as ModelDone; + expect(done.message.text, '目前沒有'); + expect(done.message.toolCalls, isEmpty); + }); + + test('assembles a tool call sent in pieces', () async { + Map piece(Map call) => { + 'choices': [ + { + 'delta': { + 'tool_calls': [call], + }, + }, + ], + }; + api.onStream = (_) async => streamed( + 200, + sse([ + piece({ + 'index': 0, + 'id': 'call_a', + 'function': {'name': 'eew', 'arguments': '{"a'}, + }), + piece({ + 'index': 0, + 'function': {'arguments': 't":1}'}, + }), + piece({ + 'index': 1, + 'id': 'call_b', + 'function': {'name': 'rts', 'arguments': '{}'}, + }), + // A usage-only chunk carries no choices. + {'choices': [], 'usage': {}}, + ]), + ); + final done = (await complete()).last as ModelDone; + final calls = done.message.toolCalls; + expect(calls.map((c) => (c.id, c.name, c.arguments)), [ + ('call_a', 'eew', '{"at":1}'), + ('call_b', 'rts', '{}'), + ]); + }); + + test('tells apart calls that come without an index', () async { + api.onStream = (_) async => streamed( + 200, + sse([ + for (final id in ['x', 'y']) + { + 'choices': [ + { + 'delta': { + 'tool_calls': [ + { + 'id': id, + 'function': { + 'name': 'eew', + 'arguments': {'id': id}, + }, + }, + ], + }, + }, + ], + }, + ]), + ); + final done = (await complete()).last as ModelDone; + expect(done.message.toolCalls.map((c) => (c.id, c.arguments)), [ + ('x', '{"id":"x"}'), + ('y', '{"id":"y"}'), + ]); + }); + + test('reports reasoning as thinking, and hides a block', () async { + api.onStream = (_) async => streamed( + 200, + sse([ + { + 'choices': [ + { + 'delta': {'reasoning_content': 'hmm'}, + }, + ], + }, + textChunk('plan\n\n答案'), + ]), + ); + final events = await complete(); + expect(events.whereType(), isNotEmpty); + expect(events.whereType().map((e) => e.delta).join(), '答案'); + expect((events.last as ModelDone).message.text, '答案'); + }); + + test( + 'reads a whole JSON answer from a service that does not stream', + () async { + api.onStream = (_) async => streamed( + 200, + Stream.value( + utf8.encode( + jsonEncode({ + 'choices': [ + { + 'message': {'role': 'assistant', 'content': '一次給完'}, + }, + ], + }), + ), + ), + contentType: 'application/json', + ); + final done = (await complete()).last as ModelDone; + expect(done.message.text, '一次給完'); + }, + ); + }); + + group('failures', () { + Future failure() async { + try { + await complete(); + } on AiException catch (e) { + return e; + } + fail('no failure'); + } + + test( + 'an HTTP error carries the status and the service\'s reason', + () async { + api.onStream = (_) async => streamed( + 401, + Stream.value( + utf8.encode('{"error":{"message":"Incorrect API key provided"}}'), + ), + contentType: 'application/json', + ); + final e = await failure(); + expect(e.kind, AiFailureKind.unauthorized); + expect(e.status, 401); + expect(e.detail, 'Incorrect API key provided'); + }, + ); + + test('an error inside a 200 stream is still an error', () async { + api.onStream = (_) async => streamed( + 200, + sse([ + { + 'error': {'code': 429, 'message': 'Rate limit exceeded'}, + }, + ]), + ); + final e = await failure(); + expect(e.kind, AiFailureKind.rateLimited); + expect(e.detail, 'Rate limit exceeded'); + }); + + test('a body that is not JSON is a bad response', () async { + api.onStream = (_) async => streamed(200, sse([''])); + expect((await failure()).kind, AiFailureKind.badResponse); + }); + + test('a dropped connection is a network failure', () async { + api.onStream = (request) async => throw DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.connectionError, + ); + expect((await failure()).kind, AiFailureKind.network); + }); + }); + + test('cancelling the stream cancels the request', () async { + final hold = Completer>(); + api.onStream = (_) => hold.future; + final sub = OpenAiChatModel(api, _connection, 'k') + .complete(messages: const [UserMessage('hi')], tools: const []) + .listen((_) {}); + await pumpEventQueue(); + await sub.cancel(); + expect(api.seen.single.cancel!.isCancelled, isTrue); + }); + + group('encoding', () { + test('an assistant tool call has null content and "{}" arguments', () { + final wire = OpenAiChatModel.encodeMessage( + const AssistantMessage( + toolCalls: [ToolCall(id: 'c', name: 'eew', arguments: '')], + ), + ); + expect(wire['content'], isNull); + expect(wire.containsKey('content'), isTrue); + final call = (wire['tool_calls']! as List).single as Map; + expect((call['function'] as Map)['arguments'], '{}'); + }); + + test('a tool result names the call it answers', () { + expect( + OpenAiChatModel.encodeMessage( + const ToolResultMessage(callId: 'c', name: 'eew', content: '[]'), + ), + {'role': 'tool', 'tool_call_id': 'c', 'content': '[]'}, + ); + }); + + test('the schema keeps parameter names that look like keywords', () { + final clean = OpenAiChatModel.sanitizeSchema({ + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'additionalProperties': {'type': 'string'}, + 'kind': { + 'type': ['string', 'null'], + 'enum': ['a', null], + }, + 'list': { + 'type': 'array', + 'items': {'type': 'object', 'additionalProperties': false}, + }, + }, + }); + expect(clean, { + 'type': 'object', + 'properties': { + 'additionalProperties': {'type': 'string'}, + 'kind': { + 'type': 'string', + 'enum': ['a'], + }, + 'list': { + 'type': 'array', + 'items': {'type': 'object', 'properties': {}}, + }, + }, + }); + }); + }); +} diff --git a/test/features/ai_chat/data/think_filter_test.dart b/test/features/ai_chat/data/think_filter_test.dart new file mode 100644 index 000000000..768826262 --- /dev/null +++ b/test/features/ai_chat/data/think_filter_test.dart @@ -0,0 +1,37 @@ +import 'package:dpip/features/ai_chat/data/think_filter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +String run(List deltas) { + final filter = ThinkFilter(); + final out = StringBuffer(); + for (final delta in deltas) { + out.write(filter.add(delta)); + } + out.write(filter.flush()); + return out.toString(); +} + +void main() { + test('an answer without a reasoning block passes through', () { + expect(run(['目前', '沒有']), '目前沒有'); + }); + + test('a block that opens the answer is hidden, even split up', () { + expect(run([' 想', '一想\n\n答', '案']), '答案'); + }); + + test('the tag anywhere else is just text', () { + expect(run(['看 這個']), '看 這個'); + }); + + test('a reply too short to be the tag is still the reply', () { + expect(run(['還在想'), ''); + expect(filter.thinking, isTrue); + expect(filter.flush(), ''); + }); +} diff --git a/test/features/ai_chat/domain/ai_connection_test.dart b/test/features/ai_chat/domain/ai_connection_test.dart new file mode 100644 index 000000000..5fb244d82 --- /dev/null +++ b/test/features/ai_chat/domain/ai_connection_test.dart @@ -0,0 +1,55 @@ +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + ({String? url, BaseUrlProblem? problem}) norm(String input) => + normalizeBaseUrl(input); + + test('a pasted endpoint, slashes and spaces reduce to the API root', () { + expect( + norm(' https://api.openai.com/v1/ ').url, + 'https://api.openai.com/v1', + ); + expect( + norm('https://api.openai.com/v1/chat/completions').url, + 'https://api.openai.com/v1', + ); + expect( + norm('https://openrouter.ai/api/v1/chat/completions/').url, + 'https://openrouter.ai/api/v1', + ); + }); + + test('plain http is refused, except to this phone', () { + expect( + norm('http://192.168.1.5:11434/v1').problem, + BaseUrlProblem.insecure, + ); + expect(norm('http://localhost:11434/v1').url, 'http://localhost:11434/v1'); + expect(norm('http://127.0.0.1:8080/v1').url, 'http://127.0.0.1:8080/v1'); + }); + + test('empty and non-URLs are named as such', () { + expect(norm(' ').problem, BaseUrlProblem.empty); + expect(norm('api.openai.com/v1').problem, BaseUrlProblem.invalid); + expect(norm('ftp://example.com').problem, BaseUrlProblem.invalid); + }); + + test('every preset is already a clean root', () { + for (final preset in aiPresets) { + expect(norm(preset.baseUrl).url, preset.baseUrl, reason: preset.name); + } + }); + + test('a connection needs both a URL and a model', () { + expect(AiConnection.empty.isComplete, isFalse); + expect( + const AiConnection(baseUrl: 'https://x/v1', model: '').isComplete, + isFalse, + ); + expect( + const AiConnection(baseUrl: 'https://x/v1', model: 'm').isComplete, + isTrue, + ); + }); +} diff --git a/test/features/ai_chat/domain/chat_agent_test.dart b/test/features/ai_chat/domain/chat_agent_test.dart new file mode 100644 index 000000000..e9c0c3348 --- /dev/null +++ b/test/features/ai_chat/domain/chat_agent_test.dart @@ -0,0 +1,271 @@ +import 'dart:async'; + +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/chat_agent.dart'; +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/chat_model.dart'; +import 'package:dpip/features/ai_chat/domain/tool_host.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fakes.dart'; + +void main() { + late FakeToolHost tools; + late List history; + + setUp(() { + tools = FakeToolHost(); + history = [const UserMessage('最近有地震嗎?')]; + }); + + Stream run(ChatAgent agent) => agent.run( + systemPrompt: (notes) => 'system ($notes)', + history: history, + record: history.add, + ); + + test('a plain answer streams its text and is recorded', () async { + final model = FakeChatModel([says('沒有。')]); + final events = await run(ChatAgent(model: model, tools: tools)).toList(); + + expect(events.whereType().map((e) => e.delta), ['沒有。']); + expect(history.last, isA()); + expect((history.last as AssistantMessage).text, '沒有。'); + // The server's notes reach the system prompt, which leads the request. + final first = model.requests.single.first as SystemMessage; + expect(first.text, 'system (server notes)'); + }); + + test('a tool call is run and its result sent back, in order', () async { + final model = FakeChatModel([ + calls([ + const ToolCall(id: 'c1', name: 'eew', arguments: '{"at":1}'), + ], text: '查一下。'), + says('目前沒有速報。'), + ]); + final events = await run(ChatAgent(model: model, tools: tools)).toList(); + + expect(tools.callsSeen.single.$1, 'eew'); + expect(tools.callsSeen.single.$2, {'at': 1}); + expect(events.whereType().single.name, 'eew'); + expect(events.whereType().single.ok, isTrue); + // History: question, the call with its text, its result, the answer. + expect(history.map((m) => m.runtimeType), [ + UserMessage, + AssistantMessage, + ToolResultMessage, + AssistantMessage, + ]); + final call = history[1] as AssistantMessage; + expect(call.text, '查一下。'); + expect(call.toolCalls.single.id, 'c1'); + final result = history[2] as ToolResultMessage; + expect(result.callId, 'c1'); + expect(result.content, 'result of eew {"at":1}'); + // The second request carried the result. + expect(model.requests[1].whereType(), hasLength(1)); + }); + + test( + 'bad arguments and unknown tools go back to the model as errors', + () async { + final model = FakeChatModel([ + calls([ + const ToolCall(id: 'a', name: 'eew', arguments: '{not json'), + const ToolCall(id: 'b', name: 'nope', arguments: '{}'), + const ToolCall(id: 'c', name: 'eew', arguments: '[1]'), + const ToolCall(id: 'd', name: 'eew', arguments: ''), + ]), + says('抱歉。'), + ]); + final events = await run(ChatAgent(model: model, tools: tools)).toList(); + + // Only the empty-argument call reached the server, as `{}`. + expect(tools.callsSeen.single.$1, 'eew'); + expect(tools.callsSeen.single.$2, isEmpty); + final finished = events.whereType().toList(); + expect(finished.map((e) => e.ok), [false, false, false, true]); + final results = history.whereType().toList(); + expect(results[0].content, contains('not a JSON object')); + expect(results[1].content, contains('no tool named "nope"')); + }, + ); + + test('calls past the per-step limit are refused, not run', () async { + final model = FakeChatModel([ + calls([ + for (var i = 0; i < 4; i++) + ToolCall(id: 'c$i', name: 'eew', arguments: '{}'), + ]), + says('好。'), + ]); + await run(ChatAgent(model: model, tools: tools, maxCallsPerStep: 2)) + .toList(); + + expect(tools.callsSeen, hasLength(2)); + final results = history.whereType().toList(); + expect(results, hasLength(4)); + expect(results[3].content, contains('at most 2 tool calls')); + }); + + test('a long result is cut, and says so', () async { + tools.answer = (_, _) => ToolOutcome('x' * 50); + final model = FakeChatModel([ + calls([const ToolCall(id: 'c', name: 'eew', arguments: '{}')]), + says('好。'), + ]); + await run(ChatAgent(model: model, tools: tools, maxResultChars: 10)) + .toList(); + + final result = history.whereType().single; + expect(result.content, startsWith('xxxxxxxxxx\n[Cut at 10 characters.')); + }); + + test('a model that never stops calling tools is stopped', () async { + final step = calls([const ToolCall(id: 'c', name: 'eew', arguments: '{}')]); + final model = FakeChatModel([step, step, step]); + await expectLater( + run(ChatAgent(model: model, tools: tools, maxSteps: 3)), + emitsThrough( + emitsError( + isA().having( + (e) => e.kind, + 'kind', + AiFailureKind.tooManySteps, + ), + ), + ), + ); + expect(model.requests, hasLength(3)); + // Every call recorded has its answer: the history stays sendable. + expect( + history.whereType().length, + history.whereType().length, + ); + }); + + test('when the tools cannot be listed, nothing is asked', () async { + tools.catalogError = const AiException(AiFailureKind.toolsUnavailable); + final model = FakeChatModel([says('不該出現')]); + await expectLater( + run(ChatAgent(model: model, tools: tools)), + emitsError(isA()), + ); + expect(model.requests, isEmpty); + expect(history, hasLength(1)); + }); + + test('a failure mid-answer keeps the text so far, once', () async { + final model = FakeChatModel([ + fails(const AiException(AiFailureKind.server), text: '目前'), + ]); + final errors = []; + final done = Completer(); + run(ChatAgent(model: model, tools: tools)) + .listen((_) {}, onError: errors.add, onDone: done.complete); + await done.future; + + expect(errors.single, isA()); + expect(history, hasLength(2)); + expect((history.last as AssistantMessage).text, '目前'); + }); + + test('a stream that ends without a message is a bad response', () async { + final model = FakeChatModel([() => const Stream.empty()]); + await expectLater( + run(ChatAgent(model: model, tools: tools)), + emitsError( + isA().having( + (e) => e.kind, + 'kind', + AiFailureKind.badResponse, + ), + ), + ); + }); + + group('cancelling', () { + test('aborts a silent model at once and records the text so far', () async { + final silent = StreamController(); + final model = FakeChatModel([() => silent.stream]); + final texts = []; + final sub = run(ChatAgent(model: model, tools: tools)).listen((e) { + if (e is AgentText) texts.add(e.delta); + }); + await pumpEventQueue(); + silent.add(const ModelText('正在')); + await pumpEventQueue(); + expect(texts, ['正在']); + + // The model says nothing more; cancel must not wait for it. + await sub.cancel(); + expect(model.cancelled, 1); + // Recorded by the time cancel returned, exactly once. + expect(history, hasLength(2)); + expect((history.last as AssistantMessage).text, '正在'); + + await pumpEventQueue(); + expect(history, hasLength(2)); + await silent.close(); + }); + + test('drops tool results that arrive after it', () async { + final release = Completer(); + tools.answer = (_, _) => release.future; + final model = FakeChatModel([ + calls([ + const ToolCall(id: 'c', name: 'eew', arguments: '{}'), + ], text: '查一下'), + ]); + final started = Completer(); + final sub = run(ChatAgent(model: model, tools: tools)).listen((e) { + if (e is AgentToolStarted) started.complete(); + }); + await started.future; + + await sub.cancel(); + release.complete(const ToolOutcome('late')); + await pumpEventQueue(); + + // The call has no answer, so it is not in the history; its text is. + expect(history.map((m) => m.runtimeType), [ + UserMessage, + AssistantMessage, + ]); + final said = history.last as AssistantMessage; + expect(said.text, '查一下'); + expect(said.toolCalls, isEmpty); + }); + + test('records nothing when nothing was said', () async { + final silent = StreamController(); + final model = FakeChatModel([() => silent.stream]); + final sub = run(ChatAgent(model: model, tools: tools)).listen((_) {}); + await pumpEventQueue(); + await sub.cancel(); + await pumpEventQueue(); + expect(history, hasLength(1)); + await silent.close(); + }); + }); + + test('results of earlier questions are compacted, the latest kept', () { + final compacted = ChatAgent.compact([ + const UserMessage('一'), + const AssistantMessage( + toolCalls: [ToolCall(id: 'a', name: 'eew', arguments: '{}')], + ), + const ToolResultMessage(callId: 'a', name: 'eew', content: 'old'), + const AssistantMessage(text: '答一'), + const UserMessage('二'), + const AssistantMessage( + toolCalls: [ToolCall(id: 'b', name: 'eew', arguments: '{}')], + ), + const ToolResultMessage(callId: 'b', name: 'eew', content: 'new'), + ]); + final results = compacted.whereType().toList(); + expect(results[0].callId, 'a'); + expect(results[0].content, contains('Earlier result omitted')); + expect(results[1].content, 'new'); + }); +} diff --git a/test/features/ai_chat/domain/system_prompt_test.dart b/test/features/ai_chat/domain/system_prompt_test.dart new file mode 100644 index 000000000..68daa13d5 --- /dev/null +++ b/test/features/ai_chat/domain/system_prompt_test.dart @@ -0,0 +1,44 @@ +import 'package:dpip/features/ai_chat/domain/system_prompt.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // A UTC-flagged instant whose fields read as Taipei wall time, as + // AppTime.utc8 gives it. + final now = DateTime.utc(2026, 10, 22, 5, 41); + + test('carries the time, the reply language and the places', () { + final prompt = buildSystemPrompt( + taipeiNow: now, + language: '繁體中文', + localeTag: 'zh-TW', + places: const [ + PromptPlace(name: '臺北市 中正區', code: '100', current: true), + PromptPlace(name: '高雄市 苓雅區', code: '802', current: false), + ], + serverNotes: ' Times are Unix seconds. ', + ); + expect( + prompt, + contains('Current time: 2026-10-22 Thursday 05:41 (Asia/Taipei, UTC+8).'), + ); + expect(prompt, contains('Reply in 繁體中文 (zh-TW)')); + expect(prompt, contains('- Current location: 臺北市 中正區 (100)')); + expect(prompt, contains('- Saved: 高雄市 苓雅區 (802)')); + expect( + prompt, + endsWith('Notes from the data server:\nTimes are Unix seconds.'), + ); + expect(prompt, contains('not an official warning service')); + }); + + test('says nothing about places or notes it does not have', () { + final prompt = buildSystemPrompt( + taipeiNow: now, + language: 'English', + localeTag: 'en', + places: const [], + ); + expect(prompt, isNot(contains("user's places"))); + expect(prompt, isNot(contains('Notes from the data server'))); + }); +} diff --git a/test/features/ai_chat/fakes.dart b/test/features/ai_chat/fakes.dart new file mode 100644 index 000000000..bf1996925 --- /dev/null +++ b/test/features/ai_chat/fakes.dart @@ -0,0 +1,219 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:dpip/core/network/api_client.dart'; +import 'package:dpip/core/network/api_region.dart'; +import 'package:dpip/core/platform/secret_store.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/chat_model.dart'; +import 'package:dpip/features/ai_chat/domain/tool_host.dart'; + +class FakeSecretStore implements SecretStore { + final Map values = {}; + bool refuseWrites = false; + + @override + Future read(SecretKey key) async => values[key]; + + @override + Future write(SecretKey key, String value) async { + if (refuseWrites) throw StateError('refused'); + values[key] = value; + } + + @override + Future delete(SecretKey key) async => values.remove(key); +} + +/// One request [FakeApi] saw. +final class SeenRequest { + SeenRequest(this.method, this.url, this.headers, this.data, this.cancel); + + final String method; + final String url; + final Map headers; + final Object? data; + final CancelToken? cancel; + + Map get json => + jsonDecode(data! as String) as Map; +} + +/// An [ApiClient] answering from scripted handlers; anything else throws. +class FakeApi implements ApiClient { + final List seen = []; + + /// Answers [streamAbsolute]. + Future> Function(SeenRequest request)? onStream; + + /// Answers [request] (the region-aware call the MCP host makes). + Future> Function(ApiTier tier, SeenRequest request)? + onRequest; + + @override + Future> streamAbsolute( + String url, { + String method = 'POST', + Object? data, + Map? headers, + Duration idleTimeout = Duration.zero, + CancelToken? cancelToken, + }) { + final request = SeenRequest(method, url, {...?headers}, data, cancelToken); + seen.add(request); + return onStream!(request); + } + + @override + Future> request( + ApiTier tier, + String path, { + String method = 'GET', + Object? data, + Map? query, + Options? options, + CancelToken? cancelToken, + }) { + final request = SeenRequest( + method, + path, + {...?options?.headers}, + data, + cancelToken, + ); + seen.add(request); + return onRequest!(tier, request); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// A streamed HTTP answer, as `streamAbsolute` returns it. +Response streamed( + int status, + Stream> body, { + String contentType = 'text/event-stream', +}) => Response( + requestOptions: RequestOptions(), + statusCode: status, + headers: Headers.fromMap({ + Headers.contentTypeHeader: [contentType], + }), + data: ResponseBody(body.map(Uint8List.fromList), status), +); + +/// An event-stream body of one `data:` frame per item, then `[DONE]`. +Stream> sse(List frames, {bool done = true}) => + Stream.fromIterable([ + for (final frame in frames) + utf8.encode('data: ${frame is String ? frame : jsonEncode(frame)}\n\n'), + if (done) utf8.encode('data: [DONE]\n\n'), + ]); + +/// A content delta chunk. +Map textChunk(String text) => { + 'choices': [ + { + 'delta': {'content': text}, + }, + ], +}; + +/// A [ChatModel] answering from a script, one entry per request. +class FakeChatModel implements ChatModel { + FakeChatModel(this.script); + + /// Each step's events; a step that is a [Completer]'s future never ends on + /// its own (for cancellation tests). + final List Function()> script; + final List> requests = []; + int cancelled = 0; + + @override + Stream complete({ + required List messages, + required List tools, + }) { + requests.add(List.of(messages)); + final step = script[requests.length - 1](); + late final StreamController out; + StreamSubscription? sub; + out = StreamController( + onListen: () { + sub = step.listen(out.add, onError: out.addError, onDone: out.close); + }, + // Not returning the inner cancel: once the step has ended it is the + // root zone's completed future, and a widget test's fake clock would + // then hold this stream's done event until the test is over. + onCancel: () { + cancelled++; + unawaited(sub?.cancel()); + }, + ); + return out.stream; + } +} + +/// A step that writes [text] and finishes with it. +Stream Function() says(String text) => + () => Stream.fromIterable([ + ModelText(text), + ModelDone(AssistantMessage(text: text)), + ]); + +/// A step that calls tools. +Stream Function() calls(List calls, {String text = ''}) => + () => Stream.fromIterable([ + if (text.isNotEmpty) ModelText(text), + ModelDone(AssistantMessage(text: text, toolCalls: calls)), + ]); + +/// A step that fails with [failure] after [text]. +Stream Function() fails(AiException failure, {String text = ''}) => + () async* { + if (text.isNotEmpty) yield ModelText(text); + throw failure; + }; + +class FakeToolHost implements ToolHost { + FakeToolHost({ + this.tools = const ['eew', 'weather_forecast', 'earthquake_reports'], + this.instructions = 'server notes', + }); + + final List tools; + final String? instructions; + final List<(String, Map)> callsSeen = []; + Object? catalogError; + + /// Answers by tool name; the default echoes the arguments. + FutureOr Function(String name, Map args)? + answer; + + @override + Future catalog() async { + if (catalogError != null) throw catalogError!; + return ToolCatalog( + tools: [ + for (final name in tools) + ToolSpec( + name: name, + description: 'tool $name', + parameters: const {'type': 'object'}, + ), + ], + instructions: instructions, + ); + } + + @override + Future call(String name, Map arguments) async { + callsSeen.add((name, arguments)); + return await answer?.call(name, arguments) ?? + ToolOutcome('result of $name ${jsonEncode(arguments)}'); + } +} diff --git a/test/features/ai_chat/presentation/ai_chat_controller_test.dart b/test/features/ai_chat/presentation/ai_chat_controller_test.dart new file mode 100644 index 000000000..b9d2bd344 --- /dev/null +++ b/test/features/ai_chat/presentation/ai_chat_controller_test.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/ai_failure.dart'; +import 'package:dpip/features/ai_chat/domain/ai_settings_repository.dart'; +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/chat_model.dart'; +import 'package:dpip/features/ai_chat/domain/system_prompt.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_chat_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../fakes.dart'; + +class FakeSettings implements AiSettingsRepository { + FakeSettings({ + this.connection = const AiConnection( + baseUrl: 'https://ai.example/v1', + model: 'm', + ), + this.key = 'sk', + }); + + @override + AiConnection connection; + String? key; + bool seen = false; + + @override + bool get introSeen => seen; + + @override + Future markIntroSeen() async => seen = true; + + @override + Future apiKey() async => key; + + @override + Future save(AiConnection connection, {String? apiKey}) async { + this.connection = connection; + if (apiKey != null) key = apiKey.isEmpty ? null : apiKey; + } + + @override + Future forget() async { + connection = AiConnection.empty; + key = null; + } + + @override + Future> listModels(String baseUrl, String? apiKey) async => []; +} + +void main() { + late FakeSettings settings; + late FakeToolHost tools; + late List built; + late List keys; + late List Function()>> scripts; + + AiChatController controller() => AiChatController( + settings: settings, + models: (connection, key) { + keys.add(key); + final model = FakeChatModel(scripts.removeAt(0)); + built.add(model); + return model; + }, + tools: tools, + places: () => const [ + PromptPlace(name: '臺北市 中正區', code: '100', current: true), + ], + taipeiNow: () => DateTime.utc(2026, 10, 22, 6), + ); + + setUp(() { + settings = FakeSettings(); + tools = FakeToolHost(); + built = []; + keys = []; + scripts = []; + }); + + Future ask(AiChatController chat, String text) async { + await chat.send(text, language: '繁體中文', localeTag: 'zh-TW'); + await pumpEventQueue(); + } + + test('a question and its answer, with the tools it used', () async { + scripts.add([ + calls([const ToolCall(id: 'c', name: 'eew', arguments: '{}')]), + says('目前沒有速報。'), + ]); + final chat = controller(); + await ask(chat, ' 有速報嗎? '); + + expect(chat.busy, isFalse); + expect(chat.entries, hasLength(2)); + expect((chat.entries[0] as QuestionEntry).text, '有速報嗎?'); + final answer = chat.entries[1] as AnswerEntry; + expect(answer.done, isTrue); + expect(answer.text, '目前沒有速報。'); + expect(answer.tools.single.name, 'eew'); + expect(answer.tools.single.state, ToolState.ok); + expect(keys, ['sk']); + + // The prompt carried the time, the language and the place. + final system = built.single.requests.first.first as SystemMessage; + expect(system.text, contains('2026-10-22')); + expect(system.text, contains('繁體中文 (zh-TW)')); + expect(system.text, contains('臺北市 中正區 (100)')); + }); + + test('a follow-up question is asked with the earlier turn', () async { + scripts + ..add([says('一')]) + ..add([says('二')]); + final chat = controller(); + await ask(chat, '第一題'); + await ask(chat, '第二題'); + final second = built[1].requests.single; + expect(second.whereType().map((m) => m.text), ['第一題', '第二題']); + expect(second.whereType().single.text, '一'); + }); + + test('a failed question can be asked again, without a duplicate', () async { + scripts + ..add([fails(const AiException(AiFailureKind.unauthorized, status: 401))]) + ..add([says('好了')]); + final chat = controller(); + await ask(chat, '有颱風嗎?'); + final failed = chat.entries[1] as AnswerEntry; + expect(failed.failure?.kind, AiFailureKind.unauthorized); + + await chat.retry(failed, language: '繁體中文', localeTag: 'zh-TW'); + await pumpEventQueue(); + expect(chat.entries, hasLength(2)); + expect((chat.entries[1] as AnswerEntry).text, '好了'); + // The unanswered first attempt is not in what was sent. + expect( + built[1].requests.single.whereType().map((m) => m.text), + ['有颱風嗎?'], + ); + }); + + test('stop keeps what was said and frees the conversation', () async { + final silent = StreamController(); + scripts + ..add([() => silent.stream]) + ..add([says('下一題')]); + final chat = controller(); + await ask(chat, '慢慢說'); + silent.add(const ModelText('這是')); + await pumpEventQueue(); + expect(chat.busy, isTrue); + + chat.stop(); + expect(chat.busy, isFalse); + final answer = chat.entries[1] as AnswerEntry; + expect(answer.stopped, isTrue); + expect(answer.done, isTrue); + + await ask(chat, '下一個'); + final sent = built[1].requests.single; + expect(sent.whereType().single.text, '這是'); + await silent.close(); + }); + + test('stop before anything was said leaves no question behind', () async { + final silent = StreamController(); + scripts + ..add([() => silent.stream]) + ..add([says('好')]); + final chat = controller(); + await ask(chat, '第一題'); + chat.stop(); + await ask(chat, '第二題'); + expect( + built[1].requests.single.whereType().map((m) => m.text), + ['第二題'], + ); + await silent.close(); + }); + + test('clear starts over', () async { + scripts + ..add([says('一')]) + ..add([says('二')]); + final chat = controller(); + await ask(chat, '第一題'); + chat.clear(); + expect(chat.isEmpty, isTrue); + await ask(chat, '第二題'); + expect(built[1].requests.single.whereType(), hasLength(1)); + }); + + test('nothing is sent without a complete setup', () async { + settings.connection = AiConnection.empty; + final chat = controller(); + await ask(chat, '有地震嗎?'); + expect(chat.isEmpty, isTrue); + expect(built, isEmpty); + }); +} diff --git a/test/features/ai_chat/presentation/ai_chat_layout_test.dart b/test/features/ai_chat/presentation/ai_chat_layout_test.dart new file mode 100644 index 000000000..f1f0ce5f2 --- /dev/null +++ b/test/features/ai_chat/presentation/ai_chat_layout_test.dart @@ -0,0 +1,77 @@ +import 'package:dpip/features/ai_chat/presentation/ai_chat_controller.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_setup.dart'; +import 'package:dpip/features/ai_chat/presentation/pages/ai_chat_page.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import '../fakes.dart'; +import 'ai_chat_controller_test.dart' show FakeSettings; + +/// The AI page as the router shows it: a full-screen page pushed over the +/// shell, on a phone with a gesture bar. The question field must clear the +/// gesture area, and clear the keyboard when it is up. +void main() { + /// The gesture area at the bottom, in logical pixels. + const gestureBar = 34.0; + + Future pumpPage(WidgetTester tester, {double keyboard = 0}) async { + tester.view + ..physicalSize = const Size(1080, 2340) + ..devicePixelRatio = 3 + // The OS reports no bottom padding under an open keyboard. + ..padding = FakeViewPadding(bottom: keyboard > 0 ? 0 : gestureBar * 3) + ..viewInsets = FakeViewPadding(bottom: keyboard * 3); + addTearDown(tester.view.reset); + final settings = FakeSettings()..seen = true; + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AiSetup(settings)), + ChangeNotifierProvider( + create: (_) => AiChatController( + settings: settings, + models: (_, _) => FakeChatModel(const []), + tools: FakeToolHost(), + places: () => const [], + taipeiNow: () => DateTime.utc(2026), + ), + ), + ], + child: MaterialApp( + locale: const Locale('zh', 'TW'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AiChatPage(), + ), + ), + ); + await tester.pump(); + } + + double fieldBottom(WidgetTester tester) => + tester.getRect(find.byType(TextField)).bottom; + + double screenHeight(WidgetTester tester) => + tester.view.physicalSize.height / tester.view.devicePixelRatio; + + testWidgets('the question field and its note clear the gesture bar', ( + tester, + ) async { + await pumpPage(tester); + final limit = screenHeight(tester) - gestureBar; + expect(fieldBottom(tester), lessThanOrEqualTo(limit)); + final disclaimer = tester.getRect(find.text('AI 可能出錯,緊急時請以官方警報為準。')); + expect(disclaimer.bottom, lessThanOrEqualTo(limit)); + }); + + testWidgets('the question field sits above the keyboard', (tester) async { + const keyboard = 300.0; + await pumpPage(tester, keyboard: keyboard); + expect( + fieldBottom(tester), + lessThanOrEqualTo(screenHeight(tester) - keyboard), + ); + }); +} diff --git a/test/features/ai_chat/presentation/ai_pages_test.dart b/test/features/ai_chat/presentation/ai_pages_test.dart new file mode 100644 index 000000000..9d735597e --- /dev/null +++ b/test/features/ai_chat/presentation/ai_pages_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:dpip/core/network/api_paths.dart'; +import 'package:dpip/features/ai_chat/domain/ai_connection.dart'; +import 'package:dpip/features/ai_chat/domain/chat_message.dart'; +import 'package:dpip/features/ai_chat/domain/chat_model.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_chat_controller.dart'; +import 'package:dpip/features/ai_chat/presentation/ai_setup.dart'; +import 'package:dpip/features/ai_chat/presentation/pages/ai_chat_page.dart'; +import 'package:dpip/features/ai_chat/presentation/pages/ai_settings_page.dart'; +import 'package:dpip/l10n/gen/app_localizations.dart'; +import 'package:dpip/shared/navigation/app_routes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../fakes.dart'; +import 'ai_chat_controller_test.dart' show FakeSettings; + +void main() { + late FakeSettings settings; + late List Function()> script; + + setUp(() { + settings = FakeSettings(); + script = []; + }); + + Future pumpApp(WidgetTester tester) async { + // Tall enough that every page is built whole: the pages are lazy lists, + // and a button below the fold does not exist to be tapped. + tester.view + ..physicalSize = const Size(1200, 6000) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, _) => const AiChatPage()), + GoRoute( + path: AppRoutes.aiSettingsPath, + name: AppRoutes.aiSettings, + builder: (_, _) => const AiSettingsPage(), + ), + ], + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => AiSetup(settings)), + ChangeNotifierProvider( + create: (_) => AiChatController( + settings: settings, + models: (_, _) => FakeChatModel(script), + tools: FakeToolHost(), + places: () => const [], + taipeiNow: () => DateTime.utc(2026, 10, 22), + ), + ), + ], + child: MaterialApp.router( + locale: const Locale('zh', 'TW'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + ), + ), + ); + await tester.pump(); + } + + testWidgets('the first visit explains, then opens the setup', (tester) async { + settings.connection = AiConnection.empty; + await pumpApp(tester); + + expect(find.text('金鑰只存在這支手機,對話不會儲存'), findsOneWidget); + expect(find.text('AI 可能出錯'), findsOneWidget); + expect(find.text(ApiPaths.mcpPublicUrl), findsOneWidget); + + await tester.tap(find.text('開始設定')); + await tester.pumpAndSettle(); + + expect(settings.introSeen, isTrue); + expect(find.byType(AiSettingsPage), findsOneWidget); + }); + + testWidgets('without a service, the chat asks for one', (tester) async { + settings + ..seen = true + ..connection = AiConnection.empty; + await pumpApp(tester); + expect(find.text('還沒設定 AI 服務'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + }); + + testWidgets('a suggestion is asked and answered, tools shown', ( + tester, + ) async { + settings.seen = true; + script + ..add(calls([const ToolCall(id: 'c', name: 'eew', arguments: '{}')])) + ..add(says('目前**沒有**速報。')); + await pumpApp(tester); + + expect(find.text('AI 可能出錯,緊急時請以官方警報為準。'), findsOneWidget); + await tester.tap(find.text('最近有地震嗎?')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('最近有地震嗎?'), findsOneWidget); + expect(find.text('已查詢地震資料'), findsOneWidget); + expect(find.textContaining('速報', findRichText: true), findsWidgets); + }); + + testWidgets('a stopped answer says so', (tester) async { + settings.seen = true; + final silent = StreamController(); + script.add(() => silent.stream); + await pumpApp(tester); + + await tester.enterText(find.byType(TextField), '慢慢說'); + await tester.pump(); + await tester.tap(find.byTooltip('傳送')); + await tester.pump(); + expect(find.text('等待回應…'), findsOneWidget); + + await tester.tap(find.byTooltip('停止')); + await tester.pump(); + expect(find.text('已停止'), findsOneWidget); + expect(find.byTooltip('傳送'), findsOneWidget); + unawaited(silent.close()); + }); + + group('settings', () { + Future openSettings(WidgetTester tester) async { + settings + ..seen = true + ..connection = AiConnection.empty + ..key = 'sk-saved'; + await pumpApp(tester); + await tester.tap(find.byTooltip('AI 設定')); + await tester.pumpAndSettle(); + } + + testWidgets('plain http to another host is refused, with why', ( + tester, + ) async { + await openSettings(tester); + await tester.enterText( + find.widgetWithText(TextField, 'API 網址'), + 'http://192.168.1.5:11434/v1', + ); + await tester.enterText(find.widgetWithText(TextField, '模型'), 'm'); + await tester.tap(find.text('儲存')); + await tester.pump(); + expect(find.textContaining('請使用 https://'), findsOneWidget); + expect(settings.connection.isComplete, isFalse); + }); + + testWidgets('a preset fills the URL; a blank key keeps the saved one', ( + tester, + ) async { + await openSettings(tester); + expect(find.text('已儲存金鑰。留空會保留原本的金鑰。'), findsOneWidget); + await tester.tap(find.text('OpenAI')); + await tester.enterText(find.widgetWithText(TextField, '模型'), 'gpt-x'); + await tester.tap(find.text('儲存')); + await tester.pumpAndSettle(); + + expect(settings.connection.baseUrl, 'https://api.openai.com/v1'); + expect(settings.connection.model, 'gpt-x'); + expect(settings.key, 'sk-saved'); + // Saved and back to the chat. + expect(find.byType(AiSettingsPage), findsNothing); + }); + }); +} diff --git a/test/features/earthquake/data/earthquake_api_test.dart b/test/features/earthquake/data/earthquake_api_test.dart index 0babdded0..3697dec76 100644 --- a/test/features/earthquake/data/earthquake_api_test.dart +++ b/test/features/earthquake/data/earthquake_api_test.dart @@ -86,6 +86,16 @@ class _RecordingApiClient implements ApiClient { CancelToken? cancelToken, }) => throw UnimplementedError(); + @override + Future> streamAbsolute( + String url, { + String method = 'POST', + Object? data, + Map? headers, + Duration idleTimeout = Duration.zero, + CancelToken? cancelToken, + }) => throw UnimplementedError(); + @override Future post( ApiTier tier, diff --git a/test/features/earthquake/replay_session_test.dart b/test/features/earthquake/replay_session_test.dart index d74063ce9..04d740513 100644 --- a/test/features/earthquake/replay_session_test.dart +++ b/test/features/earthquake/replay_session_test.dart @@ -75,6 +75,16 @@ class _RecordingApiClient implements ApiClient { CancelToken? cancelToken, }) => throw UnimplementedError(); + @override + Future> streamAbsolute( + String url, { + String method = 'POST', + Object? data, + Map? headers, + Duration idleTimeout = Duration.zero, + CancelToken? cancelToken, + }) => throw UnimplementedError(); + @override Future post( ApiTier tier, diff --git a/test/features/home/presentation/pages/home_page_test.dart b/test/features/home/presentation/pages/home_page_test.dart index 23ba63bed..776714450 100644 --- a/test/features/home/presentation/pages/home_page_test.dart +++ b/test/features/home/presentation/pages/home_page_test.dart @@ -287,6 +287,11 @@ void main() { name: AppRoutes.sponsor, builder: (_, _) => const Text('sponsor-route'), ), + GoRoute( + path: AppRoutes.aiPath, + name: AppRoutes.ai, + builder: (_, _) => const Text('ai-route'), + ), ], ); addTearDown(router.dispose); @@ -325,6 +330,18 @@ void main() { router.go(AppRoutes.homePath); await _flush(tester); + // The AI assistant's way in is the region bar's action, not a tab. + await tester.tap(find.byTooltip(l10n.aiChatTitle).hitTestable()); + await tester.pump(); + await tester.pump(); + expect( + find.text('ai-route'), + findsOneWidget, + reason: 'location ${router.state.uri}', + ); + router.go(AppRoutes.homePath); + await _flush(tester); + extent.value = 0.96; experimental.weatherMode = WeatherMode.rain; experimental.skyTimeMode = SkyTimeMode.noon; diff --git a/test/shared/widgets/region_chrome_test.dart b/test/shared/widgets/region_chrome_test.dart index 2d482f43d..fcc623dc4 100644 --- a/test/shared/widgets/region_chrome_test.dart +++ b/test/shared/widgets/region_chrome_test.dart @@ -140,4 +140,61 @@ void main() { ); expect(ignored.ignoring, isTrue); }); + + testWidgets( + 'a trailing action sits at the right and keeps the badge centred', + (tester) async { + tester.view.physicalSize = const Size(400, 200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final store = RegionStore(SettingsStore.inMemory())..select(0); + var taps = 0; + await tester.pumpWidget( + _app( + store, + RegionBar( + trailing: IconButton( + tooltip: 'action', + onPressed: () => taps++, + icon: const Icon(Icons.auto_awesome_outlined), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final l10n = AppLocalizations.of(tester.element(find.byType(RegionBar))); + + final action = tester.getRect(find.byTooltip('action')); + expect(action.right, lessThanOrEqualTo(400)); + expect(action.left, greaterThanOrEqualTo(400 - RegionBar.actionWidth)); + // The selected badge is still in the middle of the screen. + final badge = tester.getRect(find.text(l10n.regionNationwide).first); + expect(badge.center.dx, moreOrLessEquals(200, epsilon: 1)); + + await tester.tap(find.byTooltip('action')); + expect(taps, 1); + }, + ); + + testWidgets('the trailing action leaves with the bar', (tester) async { + final store = RegionStore(SettingsStore.inMemory()); + var taps = 0; + await tester.pumpWidget( + _app( + store, + RegionBar( + dismiss: 1, + trailing: IconButton( + tooltip: 'action', + onPressed: () => taps++, + icon: const Icon(Icons.auto_awesome_outlined), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('action'), warnIfMissed: false); + expect(taps, 0); + }); } From fc0e362386bec7e9fbb28267e6a43a122ae9375d Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 23 Oct 2026 05:18:01 +0800 Subject: [PATCH 3/3] docs: describe the AI assistant's endpoints and where its key is kept --- ARCHITECTURE.md | 13 +++++++++++++ api.md | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 71b96ba8c..476079847 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -377,5 +377,18 @@ app or clearing its data deletes it. Recording has no switch of its own — it follows the background-location grant — and the map's 軌跡 layer, which shows it, says all of this in its sheet; its clear goes through the native side, never a file delete from Dart. + +Secrets the user hands the app — today only the API key of their own AI +service, for the AI assistant — are the one thing persisted outside SQLite, +and on purpose: `dpip.db` sits in application-support, which iOS copies into +backups, and the AI page promises the key does not leave the phone. They go +through `SecretStore` (`core/platform/secret_store.dart`), keyed by a +`SecretKey` enum for the same reason settings take a `SettingKey`: an iOS +Keychain item with `AfterFirstUnlockThisDeviceOnly`, and on Android an +AES-GCM key generated in the Android Keystore with only the ciphertext on +disk, in `noBackupFilesDir`. +The AI service's base URL and model are ordinary settings. The AI +conversation itself is not persisted at all — it lives in memory and ends +with the process. - Every file starts with a doc comment; one public declaration = one clear responsibility. diff --git a/api.md b/api.md index 8cb9a5c1d..09dc32179 100644 --- a/api.md +++ b/api.md @@ -66,6 +66,7 @@ | `TremStationRepositoryImpl.refresh` | `/resource/station` | `coreStatic` | `static.core-{tyo1,tnn1}.exptech.dev` | | `EarthquakeApi.getReportList` | `/api/v2/eq/report` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | | `EarthquakeApi.getReport` | `/api/v2/eq/report/{id}` | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | +| `DpipMcpToolHost`(AI 對話的工具) | `/mcp`(**POST**,JSON-RPC) | `coreApi` | `api.core-{tyo1,tnn1}.exptech.dev` | > **地震報告 list(v2)query:** `limit`/`page`、`sort`/`order` > (`time`\|`intensity`\|`magnitude`\|`depth` × `asc`\|`desc`)、震度/規模/深度 @@ -76,6 +77,13 @@ > `getRtsAt` 的 `{sec}` 必須是 10 位數的 Unix 秒;剛過去的一兩分鐘可能只有一個 > 區域歸檔好(實測 now−30 s:tnn1 200、tyo1 404),所以 404 是常態而非故障。 +> **MCP `/mcp`** 是唯讀的 DPIP MCP 伺服器(Streamable HTTP,無狀態、不發 +> session id),AI 對話把它的工具交給使用者自己的 AI 服務。App 只送 +> `initialize`、`notifications/initialized`、`tools/list`、`tools/call`;回應是 +> 一則 `text/event-stream` 的 `message` 事件(也接受純 JSON)。兩個區域都有 +> (2026-10-22 實測 `x-served-by: core-tnn1` / `core-tyo1`)。AI 對話頁**顯示**給 +> 使用者自用的網址是裸主機 `https://api.core.exptech.dev/mcp`,App 自己不呼叫它。 + > **測站清單 `/resource/station` 是 CSV**(`Content-Type` 卻標成 JSON): > `loc_code,id,lat,lon,floor,code,net,time,work`,`id` 是 RTS 用的 hex 裝置 id、 > `code` 是鄉鎮代碼。每個區域的 nginx 各自產生弱 ETag(同檔不同值),帶 @@ -341,8 +349,16 @@ ETag 重新驗證。 | `ServerStatusApi.getStatus` | `https://status.exptech.dev/api/ds/query`(**POST**,Grafana datasource query;伺服器狀態頁) | | `CloudflareStatusApi.getComponents` | `https://www.cloudflarestatus.com/api/v2/components.json`(Cloudflare 元件狀態) | | `HasteApi.upload` | `https://haste.exptech.dev/api/pastes`(**POST**,上傳 App 日誌;回應的 `key` 組成 `https://haste.exptech.dev/`) | +| `OpenAiChatModel.complete` | 使用者自己設定的 AI 服務:`{baseUrl}/chat/completions`(**POST**,`stream: true`,OpenAI 相容格式;走 `ApiClient.streamAbsolute`) | +| `AiSettingsRepositoryImpl.listModels` | 同一個 AI 服務的 `{baseUrl}/models`(**GET**,走 `streamAbsolute`) | | `MlIntensityService._download` | `https://exptechtw.github.io/TREM-Lite/models/intensity_ml_v1.onnx`,失敗再試 `https://cdn.jsdelivr.net/gh/ExpTechTW/TREM-Lite@main/packages/core/static/models/intensity_ml_v1.onnx`(ML v1 震度模型,14.7 MB、gzip 傳輸約 2.3 MB) | +> **AI 服務是使用者自己的,不是 ExpTech 的。** 網址、模型與金鑰由使用者在 AI +> 設定頁填入;金鑰只存在系統金鑰庫(`SecretStore`),以 `Authorization: Bearer` +> 送給該服務。這兩個呼叫走 `streamAbsolute` 而不是 `getAbsolute`:串流回應不進 +> ETag 快取,而這些回應取決於使用者的金鑰。只允許 `https://`(`localhost` 除 +> 外)。日誌只記狀態碼與錯誤種類,不記網址、金鑰或對話內容。 + > **ML v1 模型目前沒有 ExpTech 主機提供**(`static.core-{tnn1,tyo1}` 都是 404), > 所以從 TREM-Lite 發布的位置下載,並以 SHA-256 釘住檔案:jsDelivr 跟著 > `main`,同名重訓的模型會被拒絕。第一次開強震監視器時下載,gzip 壓縮後存在