write-language 0.1.93 → 0.1.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "write-language",
3
- "version": "0.1.93",
3
+ "version": "0.1.95",
4
4
  "description": "Multi-provider language generation toolkit using Vercel AI SDK - generate responses with 10+ LLM providers including OpenAI, Anthropic, Google, and more.",
5
5
  "author": "vtempest <grokthiscontact@gmail.com>",
6
6
  "license": "rights.institute/PROSPER",
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @fileoverview Verifies that file/image attachments are forwarded to the
3
+ * Vercel AI SDK as multimodal `messages` content parts, and that plain
4
+ * (attachment-free) calls keep using the simpler `prompt` form.
5
+ */
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ // Capture the exact arguments passed to generateText so we can assert on the
9
+ // shape of the request the SDK receives.
10
+ const generateTextMock = vi.fn((_args: unknown) => Promise.resolve({ text: "ok" }));
11
+
12
+ vi.mock("ai", () => ({
13
+ generateText: (args: unknown) => generateTextMock(args),
14
+ stepCountIs: (n: number) => n,
15
+ tool: (t: unknown) => t,
16
+ }));
17
+
18
+ // Return a truthy fake model so writeLanguageResponse proceeds to generateText.
19
+ vi.mock("./provider-factory", () => ({
20
+ createLLMProvider: () => ({ id: "fake-model" }),
21
+ }));
22
+
23
+ // Avoid pulling the markdown/prism stack into the test.
24
+ vi.mock("./utils/markdown-to-html", () => ({
25
+ convertMarkdownToHTMLEscaped: async (s: string) => s,
26
+ }));
27
+
28
+ import { writeLanguageResponse } from "./generate-response";
29
+
30
+ afterEach(() => {
31
+ generateTextMock.mockClear();
32
+ });
33
+
34
+ describe("writeLanguageResponse attachments", () => {
35
+ const base = {
36
+ provider: "groq",
37
+ apiKey: "test-key",
38
+ agent: "question",
39
+ query: "Describe the attached file",
40
+ html: false,
41
+ applyContextLimit: false,
42
+ } as const;
43
+
44
+ it("sends a plain text prompt when there are no attachments", async () => {
45
+ await writeLanguageResponse({ ...base });
46
+
47
+ expect(generateTextMock).toHaveBeenCalledTimes(1);
48
+ const arg = generateTextMock.mock.calls[0][0] as Record<string, unknown>;
49
+ expect(typeof arg.prompt).toBe("string");
50
+ expect(arg.messages).toBeUndefined();
51
+ });
52
+
53
+ it("sends an image attachment as an image content part", async () => {
54
+ await writeLanguageResponse({
55
+ ...base,
56
+ attachments: [
57
+ { mediaType: "image/png", data: "data:image/png;base64,AAAA" },
58
+ ],
59
+ });
60
+
61
+ const arg = generateTextMock.mock.calls[0][0] as any;
62
+ expect(arg.prompt).toBeUndefined();
63
+ expect(Array.isArray(arg.messages)).toBe(true);
64
+ const parts = arg.messages[0].content;
65
+ expect(parts[0]).toMatchObject({ type: "text" });
66
+ expect(parts[1]).toMatchObject({
67
+ type: "image",
68
+ image: "data:image/png;base64,AAAA",
69
+ });
70
+ });
71
+
72
+ it("sends a document attachment as a file content part with its mediaType", async () => {
73
+ await writeLanguageResponse({
74
+ ...base,
75
+ attachments: [
76
+ {
77
+ mediaType: "application/pdf",
78
+ data: "JVBERi0=",
79
+ filename: "report.pdf",
80
+ },
81
+ ],
82
+ });
83
+
84
+ const arg = generateTextMock.mock.calls[0][0] as any;
85
+ const parts = arg.messages[0].content;
86
+ expect(parts[1]).toMatchObject({
87
+ type: "file",
88
+ mediaType: "application/pdf",
89
+ data: "JVBERi0=",
90
+ filename: "report.pdf",
91
+ });
92
+ });
93
+
94
+ it("skips malformed attachments (missing data or mediaType)", async () => {
95
+ await writeLanguageResponse({
96
+ ...base,
97
+ attachments: [
98
+ { mediaType: "", data: "x" } as any,
99
+ { mediaType: "image/png" } as any,
100
+ { mediaType: "image/jpeg", data: "data:image/jpeg;base64,BBBB" },
101
+ ],
102
+ });
103
+
104
+ const arg = generateTextMock.mock.calls[0][0] as any;
105
+ const parts = arg.messages[0].content;
106
+ // 1 text part + 1 valid image part only.
107
+ expect(parts).toHaveLength(2);
108
+ expect(parts[1]).toMatchObject({ type: "image" });
109
+ });
110
+ });
@@ -13,12 +13,14 @@ import type {
13
13
  AgentTool,
14
14
  GenerateLanguageOptions,
15
15
  GenerateLanguageResult,
16
+ LanguageAttachment,
16
17
  } from "./generation-types";
17
18
 
18
19
  export type {
19
20
  LLMProviderName,
20
21
  GenerateLanguageOptions,
21
22
  GenerateLanguageResult,
23
+ LanguageAttachment,
22
24
  } from "./generation-types";
23
25
  export { convertMarkdownToHTMLEscaped } from "./utils/markdown-to-html";
24
26
 
@@ -61,6 +63,7 @@ export async function writeLanguageResponse(
61
63
  temperature = 1,
62
64
  html = true,
63
65
  applyContextLimit = true,
66
+ attachments,
64
67
  ...context
65
68
  } = options;
66
69
 
@@ -144,10 +147,23 @@ export async function writeLanguageResponse(
144
147
  : undefined;
145
148
 
146
149
  // \u2500\u2500 8. Invoke LLM via Vercel AI SDK generateText \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
150
+ // When files/images are attached, issue a multimodal `messages` request so
151
+ // the model receives the uploaded content directly. Otherwise fall back to
152
+ // the simpler `prompt` form.
153
+ const fileParts = buildAttachmentParts(attachments);
147
154
  const { text: rawReply } = await generateText({
148
155
  model: llm,
149
- prompt,
150
156
  temperature,
157
+ ...(fileParts.length > 0
158
+ ? {
159
+ messages: [
160
+ {
161
+ role: "user" as const,
162
+ content: [{ type: "text" as const, text: prompt }, ...fileParts],
163
+ },
164
+ ],
165
+ }
166
+ : { prompt }),
151
167
  ...(tools && { tools, stopWhen: stepCountIs(10) }),
152
168
  });
153
169
 
@@ -172,6 +188,42 @@ export async function writeLanguageResponse(
172
188
  }
173
189
  }
174
190
 
191
+ /**
192
+ * Converts {@link LanguageAttachment}s into Vercel AI SDK message content
193
+ * parts. `image/*` attachments (or those with `kind: "image"`) become
194
+ * `image` parts; everything else becomes a `file` part carrying its
195
+ * `mediaType`. Attachments without usable data are skipped.
196
+ *
197
+ * @param attachments - File/image attachments from the caller
198
+ * @returns An array of AI SDK content parts (empty when none are supplied)
199
+ */
200
+ function buildAttachmentParts(
201
+ attachments: LanguageAttachment[] | undefined,
202
+ ): Array<
203
+ | { type: "image"; image: LanguageAttachment["data"] }
204
+ | { type: "file"; data: LanguageAttachment["data"]; mediaType: string; filename?: string }
205
+ > {
206
+ if (!Array.isArray(attachments) || attachments.length === 0) return [];
207
+
208
+ return attachments
209
+ .filter((a) => a && a.data != null && a.mediaType)
210
+ .map((a) => {
211
+ const isImage =
212
+ a.kind === "image" ||
213
+ (a.kind !== "file" && a.mediaType.toLowerCase().startsWith("image/"));
214
+
215
+ if (isImage) {
216
+ return { type: "image" as const, image: a.data };
217
+ }
218
+ return {
219
+ type: "file" as const,
220
+ data: a.data,
221
+ mediaType: a.mediaType,
222
+ ...(a.filename ? { filename: a.filename } : {}),
223
+ };
224
+ });
225
+ }
226
+
175
227
  /**
176
228
  * Substitutes `{variableName}` placeholders in a template string with values
177
229
  * from `vars`. Object/array values are pretty-printed without braces or
@@ -20,6 +20,31 @@ export type LLMProviderName =
20
20
  | "togetherai"
21
21
  | (string & {}); // preserve autocomplete while allowing arbitrary strings
22
22
 
23
+ /**
24
+ * A file or image attachment passed to the model alongside the text prompt.
25
+ *
26
+ * The Vercel AI SDK accepts these as multimodal message content parts:
27
+ * images become `{ type: "image", image }` parts and every other media type
28
+ * becomes a `{ type: "file", data, mediaType }` part. `data` may be a base64
29
+ * string, a `data:` URL, an `http(s)` URL, a `URL` instance, or raw bytes.
30
+ */
31
+ export interface LanguageAttachment {
32
+ /** MIME type, e.g. `"image/png"`, `"application/pdf"`. */
33
+ mediaType: string;
34
+ /**
35
+ * The attachment payload: a base64 string, a `data:`/`http(s)` URL, a `URL`
36
+ * instance, or raw bytes (`Uint8Array`/`ArrayBuffer`).
37
+ */
38
+ data: string | Uint8Array | ArrayBuffer | URL;
39
+ /** Optional original filename (used by providers that surface it). */
40
+ filename?: string;
41
+ /**
42
+ * Force how the part is sent. Defaults to auto-detection from `mediaType`
43
+ * (`image/*` → image part, otherwise a file part).
44
+ */
45
+ kind?: "image" | "file";
46
+ }
47
+
23
48
  /**
24
49
  * Configuration options for {@link writeLanguageResponse}.
25
50
  */
@@ -47,6 +72,12 @@ export interface GenerateLanguageOptions {
47
72
  article?: string;
48
73
  /** Prior conversation history for context-aware agents */
49
74
  chat_history?: string;
75
+ /**
76
+ * File and image attachments to send to the model alongside the prompt.
77
+ * When present, the request is issued as a multimodal `messages` call so the
78
+ * model receives the uploaded files (images and documents) directly.
79
+ */
80
+ attachments?: LanguageAttachment[];
50
81
  /** Return `HTML` (`true`) or raw Markdown (`false`). Default: `true` */
51
82
  html?: boolean;
52
83
  /** Truncate the prompt to the model's context window length. Default: `true` */
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export type {
18
18
  LLMProviderName,
19
19
  GenerateLanguageOptions,
20
20
  GenerateLanguageResult,
21
+ LanguageAttachment,
21
22
  } from "./generate-response";
22
23
 
23
24
  export type {
@@ -0,0 +1,96 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ DEFAULT_REWRITE_MODES,
4
+ getRewriteModes,
5
+ saveRewriteModes,
6
+ resetRewriteModes,
7
+ type RewriteMode,
8
+ } from './rewrite-modes'
9
+
10
+ const STORAGE_KEY = 'REASON-rewrite-modes'
11
+
12
+ // Minimal in-memory localStorage stub for the node test environment.
13
+ function createLocalStorageStub() {
14
+ const store = new Map<string, string>()
15
+ return {
16
+ getItem: vi.fn((k: string) => (store.has(k) ? store.get(k)! : null)),
17
+ setItem: vi.fn((k: string, v: string) => {
18
+ store.set(k, v)
19
+ }),
20
+ removeItem: vi.fn((k: string) => {
21
+ store.delete(k)
22
+ }),
23
+ clear: vi.fn(() => store.clear()),
24
+ _store: store,
25
+ }
26
+ }
27
+
28
+ let ls: ReturnType<typeof createLocalStorageStub>
29
+
30
+ beforeEach(() => {
31
+ ls = createLocalStorageStub()
32
+ vi.stubGlobal('localStorage', ls)
33
+ })
34
+
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals()
37
+ })
38
+
39
+ describe('DEFAULT_REWRITE_MODES', () => {
40
+ it('includes the five built-in modes with unique ids', () => {
41
+ const ids = DEFAULT_REWRITE_MODES.map((m) => m.id)
42
+ expect(ids).toEqual(['clarity', 'concise', 'summarize', 'rephrase', 'expand'])
43
+ expect(new Set(ids).size).toBe(ids.length)
44
+ })
45
+
46
+ it('gives every mode a name and prompt', () => {
47
+ for (const mode of DEFAULT_REWRITE_MODES) {
48
+ expect(mode.name).toBeTruthy()
49
+ expect(mode.prompt.length).toBeGreaterThan(0)
50
+ }
51
+ })
52
+ })
53
+
54
+ describe('getRewriteModes', () => {
55
+ it('returns the defaults when nothing is stored', () => {
56
+ expect(getRewriteModes()).toEqual(DEFAULT_REWRITE_MODES)
57
+ })
58
+
59
+ it('returns the stored modes when present', () => {
60
+ const custom: RewriteMode[] = [{ id: 'x', name: 'X', prompt: 'do x' }]
61
+ ls._store.set(STORAGE_KEY, JSON.stringify(custom))
62
+ expect(getRewriteModes()).toEqual(custom)
63
+ })
64
+
65
+ it('falls back to defaults on malformed JSON', () => {
66
+ ls._store.set(STORAGE_KEY, '{not valid json')
67
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
68
+ expect(getRewriteModes()).toEqual(DEFAULT_REWRITE_MODES)
69
+ expect(spy).toHaveBeenCalled()
70
+ })
71
+ })
72
+
73
+ describe('saveRewriteModes', () => {
74
+ it('serialises the modes to localStorage', () => {
75
+ const modes: RewriteMode[] = [{ id: 'y', name: 'Y', prompt: 'do y' }]
76
+ saveRewriteModes(modes)
77
+ expect(JSON.parse(ls._store.get(STORAGE_KEY)!)).toEqual(modes)
78
+ })
79
+
80
+ it('logs but does not throw when storage rejects', () => {
81
+ ls.setItem.mockImplementation(() => {
82
+ throw new Error('quota exceeded')
83
+ })
84
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
85
+ expect(() => saveRewriteModes([])).not.toThrow()
86
+ expect(spy).toHaveBeenCalled()
87
+ })
88
+ })
89
+
90
+ describe('resetRewriteModes', () => {
91
+ it('writes the defaults back to storage', () => {
92
+ ls._store.set(STORAGE_KEY, JSON.stringify([{ id: 'z', name: 'Z', prompt: 'z' }]))
93
+ resetRewriteModes()
94
+ expect(JSON.parse(ls._store.get(STORAGE_KEY)!)).toEqual(DEFAULT_REWRITE_MODES)
95
+ })
96
+ })
@@ -0,0 +1,63 @@
1
+ export interface RewriteMode {
2
+ id: string;
3
+ name: string;
4
+ prompt: string;
5
+ color?: string;
6
+ }
7
+
8
+ export const DEFAULT_REWRITE_MODES: RewriteMode[] = [
9
+ {
10
+ id: 'clarity',
11
+ name: 'Clarity',
12
+ prompt: 'Rewrite this paragraph for maximum clarity and straightforwardness, keeping all original meaning but removing ambiguity and simplifying complex sentences:',
13
+ color: 'blue',
14
+ },
15
+ {
16
+ id: 'concise',
17
+ name: 'Concise',
18
+ prompt: 'Rewrite this text to be more concise, removing redundancy and filler while preserving all key points and tone. Aim for about 50% of the original length:',
19
+ color: 'purple',
20
+ },
21
+ {
22
+ id: 'summarize',
23
+ name: 'Summarize',
24
+ prompt: 'Summarize the following text into a shorter paragraph, keeping the main ideas and overall tone but removing details and repetition:',
25
+ color: 'green',
26
+ },
27
+ {
28
+ id: 'rephrase',
29
+ name: 'Rephrase',
30
+ prompt: 'Rephrase this paragraph with fresh wording and more engaging style, varying sentence structure and word choice while preserving the core message:',
31
+ color: 'orange',
32
+ },
33
+ {
34
+ id: 'expand',
35
+ name: 'Expand',
36
+ prompt: 'Keep the original paragraph as-is, then expand it by adding one additional paragraph that elaborates on the main idea, gives an example, or adds helpful context for the reader:',
37
+ color: 'pink',
38
+ },
39
+ ];
40
+
41
+ const STORAGE_KEY = 'REASON-rewrite-modes';
42
+
43
+ export const getRewriteModes = (): RewriteMode[] => {
44
+ try {
45
+ const stored = localStorage.getItem(STORAGE_KEY);
46
+ if (stored) return JSON.parse(stored);
47
+ } catch (e) {
48
+ console.error('Failed to load rewrite modes:', e);
49
+ }
50
+ return DEFAULT_REWRITE_MODES;
51
+ };
52
+
53
+ export const saveRewriteModes = (modes: RewriteMode[]): void => {
54
+ try {
55
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(modes));
56
+ } catch (e) {
57
+ console.error('Failed to save rewrite modes:', e);
58
+ }
59
+ };
60
+
61
+ export const resetRewriteModes = (): void => {
62
+ saveRewriteModes(DEFAULT_REWRITE_MODES);
63
+ };