feat: translation (#389)
This commit is contained in:
@@ -144,6 +144,22 @@ class ClientService extends EventTarget {
|
||||
return result
|
||||
}
|
||||
|
||||
async signHttpAuth(url: string, method: string, description = '') {
|
||||
if (!this.signer) {
|
||||
throw new Error('Please login first to sign the event')
|
||||
}
|
||||
const event = await this.signer?.signEvent({
|
||||
content: description,
|
||||
kind: kinds.HTTPAuth,
|
||||
created_at: dayjs().unix(),
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method]
|
||||
]
|
||||
})
|
||||
return 'Nostr ' + btoa(JSON.stringify(event))
|
||||
}
|
||||
|
||||
private generateTimelineKey(urls: string[], filter: Filter) {
|
||||
const stableFilter: any = {}
|
||||
Object.entries(filter)
|
||||
|
||||
35
src/services/libre-translate.service.ts
Normal file
35
src/services/libre-translate.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
class LibreTranslateService {
|
||||
static instance: LibreTranslateService
|
||||
|
||||
constructor() {
|
||||
if (!LibreTranslateService.instance) {
|
||||
LibreTranslateService.instance = this
|
||||
}
|
||||
return LibreTranslateService.instance
|
||||
}
|
||||
|
||||
async translate(
|
||||
text: string,
|
||||
target: string,
|
||||
server?: string,
|
||||
api_key?: string
|
||||
): Promise<string | undefined> {
|
||||
if (!server) {
|
||||
throw new Error('LibreTranslate server address is not configured')
|
||||
}
|
||||
const url = new URL('/translate', server).toString()
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ q: text, target, source: 'auto', api_key })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? 'Failed to translate')
|
||||
}
|
||||
return data.translatedText
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LibreTranslateService()
|
||||
export default instance
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
TFeedInfo,
|
||||
TNoteListMode,
|
||||
TRelaySet,
|
||||
TThemeSetting
|
||||
TThemeSetting,
|
||||
TTranslationServiceConfig
|
||||
} from '@/types'
|
||||
|
||||
class LocalStorageService {
|
||||
@@ -27,6 +28,7 @@ class LocalStorageService {
|
||||
private autoplay: boolean = true
|
||||
private hideUntrustedInteractions: boolean = false
|
||||
private hideUntrustedNotifications: boolean = false
|
||||
private translationServiceConfigMap: Record<string, TTranslationServiceConfig> = {}
|
||||
|
||||
constructor() {
|
||||
if (!LocalStorageService.instance) {
|
||||
@@ -109,6 +111,13 @@ class LocalStorageService {
|
||||
? storedHideUntrustedNotifications === 'true'
|
||||
: hideUntrustedEvents
|
||||
|
||||
const translationServiceConfigMapStr = window.localStorage.getItem(
|
||||
StorageKey.TRANSLATION_SERVICE_CONFIG_MAP
|
||||
)
|
||||
if (translationServiceConfigMapStr) {
|
||||
this.translationServiceConfigMap = JSON.parse(translationServiceConfigMapStr)
|
||||
}
|
||||
|
||||
// Clean up deprecated data
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||
@@ -290,6 +299,18 @@ class LocalStorageService {
|
||||
hideUntrustedNotifications.toString()
|
||||
)
|
||||
}
|
||||
|
||||
getTranslationServiceConfig(pubkey?: string | null) {
|
||||
return this.translationServiceConfigMap[pubkey ?? '_'] ?? { service: 'jumble' }
|
||||
}
|
||||
|
||||
setTranslationServiceConfig(config: TTranslationServiceConfig, pubkey?: string | null) {
|
||||
this.translationServiceConfigMap[pubkey ?? '_'] = config
|
||||
window.localStorage.setItem(
|
||||
StorageKey.TRANSLATION_SERVICE_CONFIG_MAP,
|
||||
JSON.stringify(this.translationServiceConfigMap)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LocalStorageService()
|
||||
|
||||
55
src/services/transaction.service.ts
Normal file
55
src/services/transaction.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { JUMBLE_API_BASE_URL } from '@/constants'
|
||||
|
||||
class TransactionService {
|
||||
static instance: TransactionService
|
||||
|
||||
constructor() {
|
||||
if (!TransactionService.instance) {
|
||||
TransactionService.instance = this
|
||||
}
|
||||
return TransactionService.instance
|
||||
}
|
||||
|
||||
async createTransaction(
|
||||
pubkey: string,
|
||||
amount: number
|
||||
): Promise<{
|
||||
transactionId: string
|
||||
invoiceId: string
|
||||
}> {
|
||||
const url = new URL('/v1/transactions', JUMBLE_API_BASE_URL).toString()
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pubkey,
|
||||
amount,
|
||||
purpose: 'translation'
|
||||
})
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? 'Failed to create transaction')
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
async checkTransaction(transactionId: string): Promise<{
|
||||
state: 'pending' | 'failed' | 'settled'
|
||||
}> {
|
||||
const url = new URL(`/v1/transactions/${transactionId}/check`, JUMBLE_API_BASE_URL).toString()
|
||||
const response = await fetch(url, {
|
||||
method: 'POST'
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? 'Failed to complete transaction')
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TransactionService()
|
||||
export default instance
|
||||
129
src/services/translation.service.ts
Normal file
129
src/services/translation.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { JUMBLE_API_BASE_URL } from '@/constants'
|
||||
import client from '@/services/client.service'
|
||||
import { TTranslationAccount } from '@/types'
|
||||
|
||||
class TranslationService {
|
||||
static instance: TranslationService
|
||||
|
||||
private apiKeyMap: Record<string, string | undefined> = {}
|
||||
private currentPubkey: string | null = null
|
||||
|
||||
constructor() {
|
||||
if (!TranslationService.instance) {
|
||||
TranslationService.instance = this
|
||||
}
|
||||
return TranslationService.instance
|
||||
}
|
||||
|
||||
async getAccount(): Promise<TTranslationAccount> {
|
||||
if (!this.currentPubkey) {
|
||||
throw new Error('Please login first')
|
||||
}
|
||||
const apiKey = this.apiKeyMap[this.currentPubkey]
|
||||
const path = '/v1/translation/account'
|
||||
const method = 'GET'
|
||||
let auth: string | undefined
|
||||
if (!apiKey) {
|
||||
auth = await client.signHttpAuth(
|
||||
new URL(path, JUMBLE_API_BASE_URL).toString(),
|
||||
method,
|
||||
'Auth to get Jumble translation service account'
|
||||
)
|
||||
}
|
||||
const act = await this._fetch<TTranslationAccount>({
|
||||
path,
|
||||
method,
|
||||
auth,
|
||||
retryWhenUnauthorized: !auth
|
||||
})
|
||||
|
||||
if (act.api_key && act.pubkey) {
|
||||
this.apiKeyMap[act.pubkey] = act.api_key
|
||||
}
|
||||
|
||||
return act
|
||||
}
|
||||
|
||||
async regenerateApiKey(): Promise<string> {
|
||||
try {
|
||||
const data = await this._fetch({
|
||||
path: '/v1/translation/regenerate-api-key',
|
||||
method: 'POST'
|
||||
})
|
||||
if (data.api_key && this.currentPubkey) {
|
||||
this.apiKeyMap[this.currentPubkey] = data.api_key
|
||||
}
|
||||
return data.api_key
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : ''
|
||||
throw new Error(errMsg || 'Failed to regenerate API key')
|
||||
}
|
||||
}
|
||||
|
||||
async translate(text: string, target: string): Promise<string | undefined> {
|
||||
try {
|
||||
const data = await this._fetch({
|
||||
path: '/v1/translation/translate',
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ q: text, target })
|
||||
})
|
||||
return data.translatedText
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : ''
|
||||
throw new Error(errMsg || 'Failed to translate')
|
||||
}
|
||||
}
|
||||
|
||||
changeCurrentPubkey(pubkey: string | null): void {
|
||||
this.currentPubkey = pubkey
|
||||
}
|
||||
|
||||
private async _fetch<T = any>({
|
||||
path,
|
||||
method,
|
||||
body,
|
||||
auth,
|
||||
retryWhenUnauthorized = true
|
||||
}: {
|
||||
path: string
|
||||
method: string
|
||||
body?: string
|
||||
auth?: string
|
||||
retryWhenUnauthorized?: boolean
|
||||
}): Promise<T> {
|
||||
if (!this.currentPubkey) {
|
||||
throw new Error('Please login first')
|
||||
}
|
||||
const apiKey = this.apiKeyMap[this.currentPubkey]
|
||||
const hasApiKey = !!apiKey
|
||||
let _auth: string
|
||||
if (auth) {
|
||||
_auth = auth
|
||||
} else if (hasApiKey) {
|
||||
_auth = `Bearer ${apiKey}`
|
||||
} else {
|
||||
const act = await this.getAccount()
|
||||
_auth = `Bearer ${act.api_key}`
|
||||
}
|
||||
|
||||
const url = new URL(path, JUMBLE_API_BASE_URL).toString()
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: _auth },
|
||||
body
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (!response.ok) {
|
||||
if (data.code === '00403' && hasApiKey && retryWhenUnauthorized) {
|
||||
this.apiKeyMap[this.currentPubkey] = undefined
|
||||
return this._fetch({ path, method, body, retryWhenUnauthorized: false })
|
||||
}
|
||||
throw new Error(data.error)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TranslationService()
|
||||
export default instance
|
||||
Reference in New Issue
Block a user