Compare commits

...

8 Commits

Author SHA1 Message Date
Eugene Yurtsev d66567dd1f x 2024-02-28 13:32:31 -05:00
Eugene Yurtsev 65d814aa51 x 2024-02-28 13:32:19 -05:00
Eugene Yurtsev c2bae658d2 chmod +x 2024-02-28 13:13:24 -05:00
jacoblee93 803c587131 Add CORS note 2024-02-28 01:17:09 -08:00
jacoblee93 5b21b056df Support string outputs as well as message chunks 2024-02-28 01:03:31 -08:00
jacoblee93 59f26055e0 Lint 2024-02-28 00:38:42 -08:00
jacoblee93 24d804c073 Merge branch 'main' of https://github.com/langchain-ai/langserve into jacob/chat_widget 2024-02-28 00:38:01 -08:00
jacoblee93 31ad97b9e0 Adds chat widget for message list inputs, additional global callback for chunks 2024-02-28 00:33:14 -08:00
11 changed files with 425 additions and 312 deletions
+51 -4
View File
@@ -111,7 +111,7 @@ directory.
| **Auth** with `add_routes`: Simple authentication mechanism based on path dependencies. (No useful on its own for implementing per user logic.) | [server](https://github.com/langchain-ai/langserve/tree/main/examples/auth/path_dependencies/server.py) |
| **Auth** with `add_routes`: Implement per user logic and auth for endpoints that use per request config modifier. (**Note**: At the moment, does not integrate with OpenAPI docs.) | [server](https://github.com/langchain-ai/langserve/tree/main/examples/auth/per_req_config_modifier/server.py), [client](https://github.com/langchain-ai/langserve/tree/main/examples/auth/per_req_config_modifier/client.ipynb) |
| **Auth** with `APIHandler`: Implement per user logic and auth that shows how to search only within user owned documents. | [server](https://github.com/langchain-ai/langserve/tree/main/examples/auth/api_handler/server.py), [client](https://github.com/langchain-ai/langserve/tree/main/examples/auth/api_handler/client.ipynb) |
| **Widgets** Different widgets that can be used with playground (file upload and chat) | [server](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/server.py) |
| **Widgets** Different widgets that can be used with playground (file upload and chat) | [server](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/chat/tuples/server.py) |
| **Widgets** File upload widget used for LangServe playground. | [server](https://github.com/langchain-ai/langserve/tree/main/examples/file_processing/server.py), [client](https://github.com/langchain-ai/langserve/tree/main/examples/file_processing/client.ipynb) |
## Sample Application
@@ -161,6 +161,23 @@ if __name__ == "__main__":
uvicorn.run(app, host="localhost", port=8000)
```
If you intend to call your endpoint from the browser, you will also need to set CORS headers.
You can use FastAPI's built-in middleware for that:
```python
from fastapi.middleware.cors import CORSMiddleware
# Set all CORS enabled origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
```
### Docs
If you've deployed the server above, you can view the generated OpenAPI docs using:
@@ -532,7 +549,7 @@ Here are a few examples:
| Description | Links |
|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Widgets** Different widgets that can be used with playground (file upload and chat) | [server](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/server.py), [client](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/client.ipynb) |
| **Widgets** Different widgets that can be used with playground (file upload and chat) | [server](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/chat/tuples/server.py), [client](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/client.ipynb) |
| **Widgets** File upload widget used for LangServe playground. | [server](https://github.com/langchain-ai/langserve/tree/main/examples/file_processing/server.py), [client](https://github.com/langchain-ai/langserve/tree/main/examples/file_processing/client.ipynb) |
#### Schema
@@ -604,7 +621,7 @@ Example widget:
### Chat Widget
Look
at [widget example](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/server.py).
at the [widget example](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/chat/tuples/server.py).
To define a chat widget, make sure that you pass "type": "chat".
@@ -616,7 +633,6 @@ To define a chat widget, make sure that you pass "type": "chat".
Here's a snippet:
```python
class ChatHistory(CustomUserType):
chat_history: List[Tuple[str, str]] = Field(
...,
@@ -655,6 +671,37 @@ Example widget:
<img src="https://github.com/langchain-ai/langserve/assets/3205522/a71ff37b-a6a9-4857-a376-cf27c41d3ca4" width="50%"/>
</p>
You can also specify a list of messages as your a parameter directly, as shown in this snippet:
```python
prompt = ChatPromptTemplate.from_messages(
[
("system", "You are a helpful assisstant named Cob."),
MessagesPlaceholder(variable_name="messages"),
]
)
chain = prompt | ChatAnthropic(model="claude-2")
class MessageListInput(BaseModel):
"""Input for the chat endpoint."""
messages: List[Union[HumanMessage, AIMessage]] = Field(
...,
description="The chat messages representing the current conversation.",
extra={"widget": {"type": "chat", "input": "messages"}},
)
add_routes(
app,
chain.with_types(input_type=MessageListInput),
path="/chat",
)
```
See [this sample file](https://github.com/langchain-ai/langserve/tree/main/examples/widgets/chat/message_list/server.py) for an example.
### Enabling / Disabling Endpoints (LangServe >=0.0.33)
You can enable / disable which endpoints are exposed when adding routes for a given chain.
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python
"""Example of a simple chatbot that just passes current conversation
state back and forth between server and client.
"""
from typing import List, Union
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from langchain.chat_models import ChatAnthropic
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langserve import add_routes
from langserve.pydantic_v1 import BaseModel, Field
app = FastAPI(
title="LangChain Server",
version="1.0",
description="Spin up a simple api server using Langchain's Runnable interfaces",
)
# Set all CORS enabled origins
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
# Declare a chain
prompt = ChatPromptTemplate.from_messages(
[
("system", "You are a helpful assisstant named Cob."),
MessagesPlaceholder(variable_name="messages"),
]
)
chain = prompt | ChatAnthropic(model="claude-2") | StrOutputParser()
class InputChat(BaseModel):
"""Input for the chat endpoint."""
messages: List[Union[HumanMessage, AIMessage, SystemMessage]] = Field(
...,
description="The chat messages representing the current conversation.",
extra={"widget": {"type": "chat", "input": "messages"}},
)
add_routes(
app,
chain.with_types(input_type=InputChat),
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="localhost", port=8000)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" href="/____LANGSERVE_BASE_URL/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playground</title>
<script type="module" crossorigin src="/____LANGSERVE_BASE_URL/assets/index-fbe3df33.js"></script>
<script type="module" crossorigin src="/____LANGSERVE_BASE_URL/assets/index-a7c0cdaa.js"></script>
<link rel="stylesheet" href="/____LANGSERVE_BASE_URL/assets/index-52e8ab2f.css">
</head>
<body>
+1
View File
@@ -3,6 +3,7 @@
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "yarn@1.22.19",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -0,0 +1,189 @@
import * as ToggleGroup from "@radix-ui/react-toggle-group";
import { AutosizeTextarea } from "./AutosizeTextarea";
import TrashIcon from "../assets/TrashIcon.svg?react";
import CodeIcon from "../assets/CodeIcon.svg?react";
import ChatIcon from "../assets/ChatIcon.svg?react";
export interface MessageFields {
content: string;
additional_kwargs?: { [key: string]: unknown };
name?: string;
type?: string;
role?: string;
}
export interface ChatMessageInputArgs {
handleChange: (field: string, value: any) => void;
handleRemoval: () => void;
path: string;
message: MessageFields;
};
function isOpenAiFunctionCall(
x: unknown
): x is { name: string; arguments: string } {
if (typeof x !== "object" || x == null) return false;
if (!("name" in x) || typeof x.name !== "string") return false;
if (!("arguments" in x) || typeof x.arguments !== "string") return false;
return true;
}
export const ChatMessageInput = (props: ChatMessageInputArgs) => {
const { message } = props;
const isAiFunctionCall = isOpenAiFunctionCall(
message.additional_kwargs?.function_call
);
const type = message.type ?? "chat"
return (
<div className="control group">
<div className="flex items-start justify-between gap-2">
<select
className="-ml-1 min-w-[100px]"
value={type}
onChange={(e) => {
props.handleChange(
"type",
e.target.value
);
}}
>
<option value="human">Human</option>
<option value="ai">AI</option>
<option value="system">System</option>
<option value="function">Function</option>
<option value="chat">Chat</option>
</select>
<div className="flex items-center gap-2">
{message.type === "ai" && (
<ToggleGroup.Root
type="single"
aria-label="Message Type"
className="opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
value={isAiFunctionCall ? "function" : "text"}
onValueChange={(value) => {
switch (value) {
case "function": {
props.handleChange(
"additional_kwargs",
{
function_call: {
name: "",
arguments: "{}",
},
}
);
break;
}
case "text": {
props.handleChange(
"additional_kwargs",
{}
);
break;
}
}
}}
>
<ToggleGroup.Item
className="rounded-s border border-divider-700 px-2.5 py-1 data-[state=on]:bg-divider-500/50"
value="text"
aria-label="Text message"
>
<ChatIcon className="w-4 h-4" />
</ToggleGroup.Item>
<ToggleGroup.Item
className="rounded-e border border-l-0 border-divider-700 px-2.5 py-1 data-[state=on]:bg-divider-500/50"
value="function"
aria-label="Function call"
>
<CodeIcon className="w-4 h-4" />
</ToggleGroup.Item>
</ToggleGroup.Root>
)}
<button
className="p-1 border rounded opacity-0 transition-opacity border-divider-700 group-focus-within:opacity-100 group-hover:opacity-100"
onClick={props.handleRemoval}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
{type === "chat" && (
<input
className="mb-1"
placeholder="Role"
value={message.role ?? ""}
onChange={(e) => {
props.handleChange(
"role",
e.target.value
);
}}
/>
)}
{type === "function" && (
<input
className="mb-1"
placeholder="Function Name"
value={message.name ?? ""}
onChange={(e) => {
props.handleChange(
"name",
e.target.value
);
}}
/>
)}
{type === "ai" &&
isOpenAiFunctionCall(
message.additional_kwargs?.function_call
) ? (
<>
<input
className="mb-1"
placeholder="Function Name"
value={
message.additional_kwargs?.function_call.name ?? ""
}
onChange={(e) => {
props.handleChange(
"additional_kwargs.function_call.name",
e.target.value
);
}}
/>
<AutosizeTextarea
value={
message.additional_kwargs?.function_call?.arguments ??
""
}
onChange={(content) => {
props.handleChange(
"additional_kwargs.function_call.arguments",
content
);
}}
/>
</>
) : (
<AutosizeTextarea
value={message.content}
onChange={(content) => {
props.handleChange(
"content",
content
);
}}
/>
)}
</div>
);
}
@@ -1,8 +1,5 @@
import { withJsonFormsControlProps } from "@jsonforms/react";
import PlusIcon from "../assets/PlusIcon.svg?react";
import TrashIcon from "../assets/TrashIcon.svg?react";
import CodeIcon from "../assets/CodeIcon.svg?react";
import ChatIcon from "../assets/ChatIcon.svg?react";
import {
rankWith,
and,
@@ -10,11 +7,10 @@ import {
Paths,
isControl,
} from "@jsonforms/core";
import { AutosizeTextarea } from "./AutosizeTextarea";
import { useStreamCallback } from "../useStreamCallback";
import { getNormalizedJsonPath, traverseNaiveJsonPath } from "../utils/path";
import { isJsonSchemaExtra } from "../utils/schema";
import * as ToggleGroup from "@radix-ui/react-toggle-group";
import { MessageFields, ChatMessageInput } from "./ChatMessageInput";
import { useEffect } from "react";
export const chatMessagesTester = rankWith(
12,
@@ -61,98 +57,48 @@ export const chatMessagesTester = rankWith(
)
);
interface MessageFields {
content: string;
additional_kwargs?: { [key: string]: unknown };
name?: string;
type?: string;
role?: string;
}
function isMessageFields(x: unknown): x is MessageFields {
if (typeof x !== "object" || x == null) return false;
if (!("content" in x) || typeof x.content !== "string") return false;
if (
"additional_kwargs" in x &&
typeof x.additional_kwargs !== "object" &&
x.additional_kwargs != null
)
return false;
if ("name" in x && typeof x.name !== "string" && x.name != null) return false;
if ("type" in x && typeof x.type !== "string" && x.type != null) return false;
if ("role" in x && typeof x.role !== "string" && x.role != null) return false;
return true;
}
function constructMessage(
x: unknown,
assumedRole: string
): Array<MessageFields> | null {
if (typeof x === "string") {
return [{ content: x, type: assumedRole }];
}
if (isMessageFields(x)) {
return [x];
}
if (Array.isArray(x) && x.every(isMessageFields)) {
return x;
}
return null;
}
function isOpenAiFunctionCall(
x: unknown
): x is { name: string; arguments: string } {
if (typeof x !== "object" || x == null) return false;
if (!("name" in x) || typeof x.name !== "string") return false;
if (!("arguments" in x) || typeof x.arguments !== "string") return false;
return true;
}
export const ChatMessagesControlRenderer = withJsonFormsControlProps(
(props) => {
const data: Array<MessageFields> = props.data ?? [];
useStreamCallback("onSuccess", (ctx) => {
useEffect(() => {
if (!isJsonSchemaExtra(props.schema)) return;
const widget = props.schema.extra.widget;
if (!("input" in widget) && !("output" in widget)) return;
if (props.schema.extra.widget.type !== "chat") return;
setTimeout(() => props.handleChange(props.path, [
...data,
{ content: "", type: "human" },
]), 10);
}, []);
const inputPath = getNormalizedJsonPath(widget.input ?? "");
const outputPath = getNormalizedJsonPath(widget.output ?? "");
useStreamCallback("onStart", () => {
if (!isJsonSchemaExtra(props.schema)) return;
if (props.schema.extra.widget.type !== "chat") return;
props.handleChange(props.path, [...data, { content: "", type: "ai" }]);
});
const human = traverseNaiveJsonPath(ctx.input, inputPath);
let ai = traverseNaiveJsonPath(ctx.output, outputPath);
const isSingleOutputKey =
ctx.output != null &&
Object.keys(ctx.output).length === 1 &&
Object.keys(ctx.output)[0] === "output";
if (isSingleOutputKey) {
ai = traverseNaiveJsonPath(ai, ["output", ...outputPath]) ?? ai;
useStreamCallback("onChunk", (_chunk, aggregatedState) => {
if (!isJsonSchemaExtra(props.schema)) return;
if (props.schema.extra.widget.type !== "chat") return;
if (aggregatedState?.final_output !== undefined) {
const msgPath = Paths.compose(props.path, `${data.length - 1}`);
if ((aggregatedState.final_output as MessageFields)?.type === "AIMessageChunk") {
props.handleChange(
Paths.compose(msgPath, "content"),
(aggregatedState.final_output as MessageFields)?.content
);
} else if (typeof aggregatedState.final_output === "string") {
props.handleChange(
Paths.compose(msgPath, "content"),
aggregatedState.final_output
);
}
}
});
const humanMsg = constructMessage(human, "human");
const aiMsg = constructMessage(ai, "ai");
let newMessages = undefined;
if (humanMsg != null) {
newMessages ??= [...data];
newMessages.push(...humanMsg);
}
if (aiMsg != null) {
newMessages ??= [...data];
newMessages.push(...aiMsg);
}
if (newMessages != null) {
props.handleChange(props.path, newMessages);
}
useStreamCallback("onSuccess", () => {
if (!isJsonSchemaExtra(props.schema)) return;
if (props.schema.extra.widget.type !== "chat") return;
props.handleChange(props.path, [...data, { content: "", type: "human" }]);
});
return (
@@ -178,174 +124,25 @@ export const ChatMessagesControlRenderer = withJsonFormsControlProps(
<div className="flex flex-col gap-3 mt-1 empty:hidden">
{data.map((message, index) => {
const msgPath = Paths.compose(props.path, `${index}`);
const type = message.type ?? "chat";
const isAiFunctionCall = isOpenAiFunctionCall(
message.additional_kwargs?.function_call
);
const handleChatMessageChange = (field: string, value: any) => {
props.handleChange(Paths.compose(msgPath, field), value);
};
const handleChatMessageRemoval = () => {
props.handleChange(
props.path,
data.filter((_, i) => i !== index)
);
}
return (
<div className="control group" key={index}>
<div className="flex items-start justify-between gap-2">
<select
className="-ml-1 min-w-[100px]"
value={type}
onChange={(e) => {
props.handleChange(
Paths.compose(msgPath, "type"),
e.target.value
);
}}
>
<option value="human">Human</option>
<option value="ai">AI</option>
<option value="system">System</option>
<option value="function">Function</option>
<option value="chat">Chat</option>
</select>
<div className="flex items-center gap-2">
{message.type === "ai" && (
<ToggleGroup.Root
type="single"
aria-label="Message Type"
className="opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
value={isAiFunctionCall ? "function" : "text"}
onValueChange={(value) => {
switch (value) {
case "function": {
props.handleChange(
Paths.compose(msgPath, "additional_kwargs"),
{
function_call: {
name: "",
arguments: "{}",
},
}
);
break;
}
case "text": {
props.handleChange(
Paths.compose(msgPath, "additional_kwargs"),
{}
);
break;
}
}
}}
>
<ToggleGroup.Item
className="rounded-s border border-divider-700 px-2.5 py-1 data-[state=on]:bg-divider-500/50"
value="text"
aria-label="Text message"
>
<ChatIcon className="w-4 h-4" />
</ToggleGroup.Item>
<ToggleGroup.Item
className="rounded-e border border-l-0 border-divider-700 px-2.5 py-1 data-[state=on]:bg-divider-500/50"
value="function"
aria-label="Function call"
>
<CodeIcon className="w-4 h-4" />
</ToggleGroup.Item>
</ToggleGroup.Root>
)}
<button
className="p-1 border rounded opacity-0 transition-opacity border-divider-700 group-focus-within:opacity-100 group-hover:opacity-100"
onClick={() => {
props.handleChange(
props.path,
data.filter((_, i) => i !== index)
);
}}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
{type === "chat" && (
<input
className="mb-1"
placeholder="Role"
value={message.role ?? ""}
onChange={(e) => {
props.handleChange(
Paths.compose(msgPath, "role"),
e.target.value
);
}}
/>
)}
{type === "function" && (
<input
className="mb-1"
placeholder="Function Name"
value={message.name ?? ""}
onChange={(e) => {
props.handleChange(
Paths.compose(msgPath, "name"),
e.target.value
);
}}
/>
)}
{type === "ai" &&
isOpenAiFunctionCall(
message.additional_kwargs?.function_call
) ? (
<>
<input
className="mb-1"
placeholder="Function Name"
value={
message.additional_kwargs?.function_call.name ?? ""
}
onChange={(e) => {
props.handleChange(
Paths.compose(
msgPath,
"additional_kwargs.function_call.name"
),
e.target.value
);
}}
/>
<AutosizeTextarea
value={
message.additional_kwargs?.function_call?.arguments ??
""
}
onChange={(content) => {
props.handleChange(
Paths.compose(
msgPath,
"additional_kwargs.function_call.arguments"
),
content
);
}}
/>
</>
) : (
<AutosizeTextarea
value={message.content}
onChange={(content) => {
props.handleChange(
Paths.compose(msgPath, "content"),
content
);
}}
/>
)}
</div>
<ChatMessageInput
message={message}
handleChange={handleChatMessageChange}
handleRemoval={handleChatMessageRemoval}
path={props.path}
key={index}
></ChatMessageInput>
);
})}
</div>
+4
View File
@@ -1,5 +1,9 @@
import type { Operation } from "fast-json-patch";
import type { RunState } from "./useStreamLog";
export interface StreamCallback {
onSuccess?: (ctx: { input: unknown; output: unknown }) => void;
onChunk?: (chunk: { ops?: Operation[] }, aggregatedState: RunState | null) => void;
onError?: () => void;
onStart?: (ctx: { input: unknown }) => void;
}
@@ -10,6 +10,7 @@ import { StreamCallback } from "./types";
export const AppCallbackContext = createContext<MutableRefObject<{
onStart: Exclude<StreamCallback["onStart"], undefined>[];
onChunk: Exclude<StreamCallback["onChunk"], undefined>[];
onSuccess: Exclude<StreamCallback["onSuccess"], undefined>[];
onError: Exclude<StreamCallback["onError"], undefined>[];
}> | null>(null);
@@ -18,9 +19,10 @@ export function useAppStreamCallbacks() {
// callbacks handling
const context = useRef<{
onStart: Exclude<StreamCallback["onStart"], undefined>[];
onChunk: Exclude<StreamCallback["onChunk"], undefined>[];
onSuccess: Exclude<StreamCallback["onSuccess"], undefined>[];
onError: Exclude<StreamCallback["onError"], undefined>[];
}>({ onStart: [], onSuccess: [], onError: [] });
}>({ onStart: [], onChunk: [], onSuccess: [], onError: [] });
const callbacks: StreamCallback = {
onStart(...args) {
@@ -28,6 +30,11 @@ export function useAppStreamCallbacks() {
callback(...args);
}
},
onChunk(...args) {
for (const callback of context.current.onChunk) {
callback(...args);
}
},
onSuccess(...args) {
for (const callback of context.current.onSuccess) {
callback(...args);
@@ -44,7 +51,7 @@ export function useAppStreamCallbacks() {
}
export function useStreamCallback<
Type extends "onStart" | "onSuccess" | "onError"
Type extends "onStart" | "onChunk" | "onSuccess" | "onError"
>(type: Type, callback: Exclude<StreamCallback[Type], undefined>) {
type CallbackType = Exclude<StreamCallback[Type], undefined>;
@@ -52,6 +52,9 @@ export function useStreamLog(callbacks: StreamCallback = {}) {
const startRef = useRef(callbacks.onStart);
startRef.current = callbacks.onStart;
const chunkRef = useRef(callbacks.onChunk);
chunkRef.current = callbacks.onChunk;
const successRef = useRef(callbacks.onSuccess);
successRef.current = callbacks.onSuccess;
@@ -74,6 +77,7 @@ export function useStreamLog(callbacks: StreamCallback = {}) {
if (msg.event === "data") {
innerLatest = reducer(innerLatest, JSON.parse(msg.data)?.ops);
setLatest(innerLatest);
chunkRef.current?.(JSON.parse(msg.data), innerLatest);
}
},
openWhenHidden: true,