spiegel-keyman/common/predictive-text/worker/index.ts

417 lines
15 KiB
TypeScript

/*
* Copyright (c) 2018 National Research Council Canada (author: Eddie A. Santos)
* Copyright (c) 2018 SIL International
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/**
* @file index.ts
*
* The main LMLayerWorker class, the top-level class within the Web Worker.
* The LMLayerWorker handles the keyboard/worker communication
* protocol, delegating prediction requests to the language
* model implementations.
*/
/// <reference path="../message.d.ts" />
/// <reference path="models/dummy-model.ts" />
/// <reference path="../node_modules/@keymanapp/models-wordbreakers/src/index.ts" />
/// <reference path="./model-compositor.ts" />
/**
* Encapsulates all the state required for the LMLayer's worker thread.
*
* Implements the state pattern. There are three states:
*
* - `unconfigured` (initial state before configuration)
* - `modelless` (state without model loaded)
* - `ready` (state with model loaded, accepts prediction requests)
*
* Transitions are initiated by valid messages. Invalid
* messages are errors, and do not lead to transitions.
*
* +-------------+ load +---------+
* config | |----------->| |
* +-------> modelless + + ready +---+
* | |<-----------| | |
* +-------------+ unload +----^----+ | predict
* | |
* +--------+
*
* The model and the configuration are ONLY relevant in the `ready` state;
* as such, they are NOT direct properties of the LMLayerWorker.
*/
class LMLayerWorker {
/**
* State pattern. This object handles onMessage().
* handleMessage() can transition to a different state, if
* necessary.
*/
private state: LMLayerWorkerState;
/**
* By default, it's self.postMessage(), but can be overridden
* so that this can be tested **outside of a Worker**.
*/
private _postMessage: PostMessage;
/**
* By default, it's self.importScripts(), but can be overridden
* so that this can be tested **outside of a Worker**.
*
* To function properly, self.importScripts() must be bound to self
* before being stored here, else it will fail.
*/
private _importScripts: ImportScripts;
private self: any;
private _platformCapabilities: Capabilities;
private _hostURL: string;
private _currentModelSource: ModelSourceSpec;
constructor(options = {
importScripts: null,
postMessage: null
}) {
this._postMessage = options.postMessage || postMessage;
this._importScripts = options.importScripts || importScripts;
this.setupConfigState();
}
public error(message: string, error?: any) {
// error isn't a fan of being cloned across the worker boundary.
this.cast('error', {
log: message,
error: (error && error.stack) ? error.stack : undefined
});
}
/**
* A function that can be set as self.onmessage (the Worker
* message handler).
* NOTE! You must bind it to a specific instance, e.g.:
*
* // Do this!
* self.onmessage = worker.onMessage.bind(worker);
*
* Incorrect:
*
* // Don't do this!
* self.onmessage = worker.onMessage;
*
* See: .install();
*/
onMessage(event: MessageEvent) {
const {message} = event.data;
// Ensure the message is tagged with a valid message tag.
if (!message) {
throw new Error(`Missing required 'message' property: ${event.data}`)
}
// If last load was for this exact model file, squash the message.
// (Though not if we've had an unload since.)
let im = event.data as IncomingMessage;
if(im.message == 'load') {
let data = im as LoadMessage;
let duplicated = false;
if(this._currentModelSource && data.source.type == this._currentModelSource.type) {
if(data.source.type == 'file' && data.source.file == (this._currentModelSource as ModelFile).file) {
duplicated = true;
} else if(data.source.type == 'raw' && data.source.code == (this._currentModelSource as ModelEval).code) {
duplicated = true;
}
}
if(duplicated) {
// Some JS implementations don't allow web workers access to the console.
if(typeof console !== 'undefined') {
console.warn("Duplicate model load message detected - squashing!");
}
return;
} else {
this._currentModelSource = data.source;
}
} else if(im.message == 'unload') {
this._currentModelSource = null;
}
// We got a message! Delegate to the current state.
this.state.handleMessage(im);
}
/**
* Sends back a message structured according to the protocol.
* @param message A message type.
* @param payload The message's payload. Can have any properties, except 'message'.
*/
private cast(message: OutgoingMessageKind, payload: Object) {
// Chrome raises "TypeError: invalid invocation" if postMessage is called
// with any non-default value for `this`, i.e., this won't work:
//
// this._postMessage({ foo: 'bar' });
//
// Yank it postMessage() off of `this` so that it's called on the
// "global" context, and everything works again.
let postMessage = this._postMessage;
postMessage({ message, ...payload });
}
/**
* Loads a model by executing the given source code, and
* passing in the appropriate configuration.
*
* @param desc Type of the model to instantiate and its parameters.
* @param capabilities Capabilities on offer from the keyboard.
*/
public loadModel(model: LexicalModel) {
// TODO: pass _platformConfig to model so that it can self-configure to the platform,
// returning a Configuration.
/* Note that this function is typically called from within an `importScripts` call.
* For meaningful error messages to be successfully logged, we must catch what we can here
* and pass a message to outside the worker - otherwise a generic "Script error" occurs.
*/
try {
let configuration = model.configure(this._platformCapabilities);
// Handle deprecations.
if(!configuration.leftContextCodePoints) {
configuration.leftContextCodePoints = configuration.leftContextCodeUnits;
}
if(!configuration.rightContextCodePoints) {
configuration.rightContextCodePoints = configuration.rightContextCodeUnits;
}
// Set reasonable defaults for the configuration.
if (!configuration.leftContextCodePoints) {
configuration.leftContextCodePoints = this._platformCapabilities.maxLeftContextCodePoints;
}
if (!configuration.rightContextCodePoints) {
configuration.rightContextCodePoints = this._platformCapabilities.maxRightContextCodePoints || 0;
}
// Ensures that default casing rules exist for custom models that request casing rules but don't define them.
if(model.languageUsesCasing && !model.applyCasing) {
model.applyCasing = models.defaultApplyCasing;
}
let compositor = this.transitionToReadyState(model);
// This test allows models to directly specify the property without it being auto-overridden by
// this default.
if(configuration.wordbreaksAfterSuggestions === undefined) {
configuration.wordbreaksAfterSuggestions = (compositor.punctuation.insertAfterWord != '');
}
this.cast('ready', { configuration });
} catch (err) {
this.error("loadModel failed!", err);
}
}
private loadModelFile(url: string) {
// The self/global WebWorker method, allowing us to directly import another script file into WebWorker scope.
// If built correctly, the model's script file will auto-register the model with loadModel() above.
try {
this._importScripts(url);
} catch (err) {
this.error("Error occurred when attempting to load dictionary", err);
}
}
public unloadModel() {
// Right now, this seems sufficient to clear out the old model.
// The only existing reference to a loaded model is held by
// transitionToReadyState's `handleMessage` closure. (The `model` var)
this.transitionToLoadingState();
}
/**
* Sets the initial state, i.e., `unconfigured`.
* This state only handles `config` messages, and will
* transition to the `modelless` state once it receives
* the config data from the host platform.
*/
private setupConfigState() {
this.state = {
name: 'unconfigured',
handleMessage: (payload) => {
// ... that message must have been 'config'!
if (payload.message !== 'config') {
throw new Error(`invalid message; expected 'config' but got ${payload.message}`);
}
this._platformCapabilities = payload.capabilities;
this.transitionToLoadingState();
}
}
}
/**
* Sets the model-loading state, i.e., `modelless`.
* This state only handles `load` messages, and will
* transition to the `ready` state once it receives a model
* description and capabilities.
*/
private transitionToLoadingState() {
let _this = this;
this.state = {
name: 'modelless',
handleMessage: (payload) => {
// ...that message must have been 'load'!
if (payload.message !== 'load') {
throw new Error(`invalid message; expected 'load' but got ${payload.message}`);
}
// TODO: validate configuration?
if(payload.source.type == 'file') {
_this.loadModelFile(payload.source.file);
} else {
// Creates a closure capturing all top-level names that the model must be able to reference.
// `eval` runs by scope rules; our virtualized worker needs a special scope for this to work.
//
// Reference: https://stackoverflow.com/a/40108685
// Note that we don't need `this`, but we do need the namespaces seen below.
let code = payload.source.code;
let evalInContext = function(LMLayerWorker, models, correction, wordBreakers) {
eval(code);
}
evalInContext(_this, models, correction, wordBreakers);
}
}
};
}
/**
* Sets the state to `ready`. This requires a
* fully-instantiated model. The `ready` state only responds
* to `predict` message, and is an accepting state.
*
* @param model The loaded language model.
*/
private transitionToReadyState(model: LexicalModel): ModelCompositor {
let compositor = new ModelCompositor(model);
this.state = {
name: 'ready',
handleMessage: (payload) => {
switch(payload.message) {
case 'predict':
var {transform, context} = payload;
var suggestions = compositor.predict(transform, context);
// Now that the suggestions are ready, send them out!
this.cast('suggestions', {
token: payload.token,
suggestions: suggestions
});
break;
case 'wordbreak':
let brokenWord = models.wordbreak(model.wordbreaker || wordBreakers.default, payload.context);
this.cast('currentword', {
token: payload.token,
word: brokenWord
});
break;
case 'unload':
this.unloadModel();
break;
case 'accept':
var {suggestion, context, postTransform} = payload;
var reversion = compositor.acceptSuggestion(suggestion, context, postTransform);
this.cast('postaccept', {
token: payload.token,
reversion: reversion
});
break;
case 'revert':
var {reversion, context} = payload;
var suggestions: Suggestion[] = compositor.applyReversion(reversion, context);
this.cast('postrevert', {
token: payload.token,
suggestions: suggestions
});
break;
case 'reset-context':
var {context} = payload;
compositor.resetContext(context);
break;
default:
throw new Error(`invalid message; expected one of {'predict', 'wordbreak', 'accept', 'revert', 'reset-context', 'unload'} but got ${payload.message}`);
}
},
compositor: compositor
};
return compositor;
}
/**
* Creates a new instance of the LMLayerWorker, and installs all its
* functions within the provided Worker global scope.
*
* In production, this is called within the Worker's scope as:
*
* LMLayerWorker.install(self);
*
* ...and this will setup onmessage and postMessage() appropriately.
*
* During testing, this method is useful to mock an entire global scope,
*
* var fakeScope = { postMessage: ... };
* LMLayerWorker.install(fakeScope);
* // now we can spy on methods in fakeScope!
*
* @param scope A global scope to install upon.
*/
static install(scope: DedicatedWorkerGlobalScope): LMLayerWorker {
let worker = new LMLayerWorker({ postMessage: scope.postMessage, importScripts: scope.importScripts.bind(scope) });
scope.onmessage = worker.onMessage.bind(worker);
worker.self = scope;
// Ensures that the worker instance is accessible for loaded model scripts.
// Assists unit-testing.
scope['LMLayerWorker'] = worker;
scope['models'] = models;
scope['correction'] = correction;
scope['wordBreakers'] = wordBreakers;
return worker;
}
}
// Let LMLayerWorker be available both in the browser and in Node.
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = LMLayerWorker;
module.exports['correction'] = correction;
module.exports['models'] = models;
module.exports['wordBreakers'] = wordBreakers;
/// XXX: export the ModelCompositor for testing.
module.exports['ModelCompositor'] = ModelCompositor;
} else if (typeof self !== 'undefined' && 'postMessage' in self) {
// Automatically install if we're in a Web Worker.
LMLayerWorker.install(self as any); // really, 'as typeof globalThis', but we're currently getting TS errors from use of that.
} else {
//@ts-ignore
window.LMLayerWorker = LMLayerWorker;
}