feat: translation (#389)

This commit is contained in:
Cody Tseng
2025-06-23 23:52:21 +08:00
committed by GitHub
parent e2e115ebeb
commit df9066eae0
43 changed files with 1466 additions and 47 deletions

View File

@@ -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)

View 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

View File

@@ -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()

View 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

View 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