Compare commits

..

18 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
Jacob Lee ff07eec3d2 Update JS docs links (#490)
@eyurtsev
2024-02-28 00:37:26 -08:00
jacoblee93 31ad97b9e0 Adds chat widget for message list inputs, additional global callback for chunks 2024-02-28 00:33:14 -08:00
Nat Noordanus a01ab330ac Make playground work with apps behind a proxy with root_path (#472)
Make the playground respect the
[root_path](https://fastapi.tiangolo.com/advanced/behind-a-proxy/) from
the request scope.

Also streamline some related code in APIHandler around normalizing
trailing slashes on paths.
2024-02-27 21:51:51 -05:00
jakerachleff c1d52441f6 Update Hosted LangServe Signup Link (#485) 2024-02-26 14:31:02 -08:00
Eugene Yurtsev 53f265a0f8 Release 0.0.43 (#484)
See release notes
2024-02-26 13:19:48 -05:00
Eugene Yurtsev 25ebe634f2 Fix: Pass dependencies to doc endpont for stream (#483)
This PR fixes a bug that wasn't making dependency information
show up for the documentation of the stream endpoint.

The dependencies were correctly applied for the endpoint itself,
so only the documentation is affected for /stream endpoint.
2024-02-26 13:19:18 -05:00
Eugene Yurtsev 594527e90b Update .clabot (#480) 2024-02-22 10:22:16 -05:00
Eugene Yurtsev ddb6ecbdbf release version 0.0.42 (#477)
see release notes
2024-02-22 10:11:45 -05:00
Eugene Yurtsev e1cec832da patch: name used for tracing should take into account APIRouter path (#476)
If there is a router at path /foo, and a runnable is added to that
router at
path /bar, the name of the runnable should be /foo/bar for logging
purposes.
2024-02-22 10:09:02 -05:00
Eugene Yurtsev 54725a91ed Examples: Add multiple servers example (#469)
Adds an example of using multiple servers
2024-02-14 13:11:55 -05:00
Eugene Yurtsev d46017048e Update Readme to include path information when disabling the playground (#464)
Clarify how to disable/enable endpoints -- i.e., it's provided as an
extra argument when invoking add_routes
2024-02-13 11:56:54 -05:00
19 changed files with 590 additions and 338 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
{
"contributors": ["eyurtsev", "hwchase17", "nfcampos", "efriis", "jacoblee93", "dqbd", "kreneskyp", "adarsh-jha-dev", "harris", "baskaryan", "hinthornw", "bracesproul", "jakerachleff", "craigsdennis", "anhi", "169", "LarchLiu", "PaulLockett", "RCMatthias", "jwynia", "majiayu000", "mpskex", "shivachittamuru", "sinashaloudegi", "sowsan", "akira", "lucianotonet", "JGalego"],
"contributors": ["eyurtsev", "hwchase17", "nfcampos", "efriis", "jacoblee93", "dqbd", "kreneskyp", "adarsh-jha-dev", "harris", "baskaryan", "hinthornw", "bracesproul", "jakerachleff", "craigsdennis", "anhi", "169", "LarchLiu", "PaulLockett", "RCMatthias", "jwynia", "majiayu000", "mpskex", "shivachittamuru", "sinashaloudegi", "sowsan", "akira", "lucianotonet", "JGalego", "nat-n"],
"message": "Thank you for your pull request and welcome to our community. We require contributors to sign our Contributor License Agreement, and we don't seem to have the username {{usersWithoutCLA}} on file. In order for us to review and merge your code, please complete the Individual Contributor License Agreement here https://forms.gle/AQFbtkWRoHXUgipM6 .\n\nThis process is done manually on our side, so after signing the form one of the maintainers will add you to the contributors list.\n\nFor more details about why we have a CLA and other contribution guidelines please see: https://github.com/langchain-ai/langserve/blob/main/CONTRIBUTING.md."
}
+61 -13
View File
@@ -20,8 +20,8 @@ uses [pydantic](https://docs.pydantic.dev/latest/) for data validation.
In addition, it provides a client that can be used to call into runnables deployed on a
server.
A javascript client is available
in [LangChainJS](https://api.js.langchain.com/classes/langchain_runnables_remote.RemoteRunnable.html).
A JavaScript client is available
in [LangChain.js](https://js.langchain.com/docs/ecosystem/langserve).
## Features
@@ -53,7 +53,7 @@ in [LangChainJS](https://api.js.langchain.com/classes/langchain_runnables_remote
We will be releasing a hosted version of LangServe for one-click deployments of
LangChain
applications. [Sign up here](https://airtable.com/app0hN6sd93QcKubv/shrAjst60xXa6quV2)
applications. [Sign up here](https://airtable.com/apppQ9p5XuujRl3wJ/shrABpHWdxry8Bacm)
to get on the waitlist.
## Security
@@ -84,7 +84,7 @@ installed. You can install it with `pip install -U langchain-cli`.
langchain app new ../path/to/directory
```
## <a name="examples"></a> Examples & Templates
## Examples
Get your LangServe instance started quickly with
[LangChain Templates](https://github.com/langchain-ai/langchain/blob/master/templates/README.md).
@@ -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:
@@ -223,7 +240,7 @@ chain.batch([{"topic": "parrots"}, {"topic": "cats"}])
In TypeScript (requires LangChain.js version 0.0.166 or later):
```typescript
import {RemoteRunnable} from "langchain/runnables/remote";
import { RemoteRunnable } from "@langchain/core/runnables/remote";
const chain = new RemoteRunnable({
url: `http://localhost:8000/joke/`,
@@ -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,21 +671,53 @@ 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. Use `enabled_endpoints` if you
want to make sure to never get a new endpoint when upgrading langserve to a newer
You can enable / disable which endpoints are exposed when adding routes for a given chain.
Use `enabled_endpoints` if you want to make sure to never get a new endpoint when upgrading langserve to a newer
verison.
Enable: The code below will only enable `invoke`, `batch` and the
corresponding `config_hash` endpoint variants.
```python
add_routes(app, chain, enabled_endpoints=["invoke", "batch", "config_hashes"])
add_routes(app, chain, enabled_endpoints=["invoke", "batch", "config_hashes"], path="/mychain")
```
Disable: The code below will disable the playground for the chain
```python
add_routes(app, chain, disabled_endpoints=["playground"])
add_routes(app, chain, disabled_endpoints=["playground"], path="/mychain")
```
@@ -0,0 +1,35 @@
"""Client server that interacts with the main server via a remote runnable.
This server sets up a simple proxy to the main server. It uses the RemoteRunnable
to interact with the main server. The main server is expected to be running at
http://localhost:8123.
A client server will likely end up doing something more clever rather than
just being a proxy.
"""
from fastapi import FastAPI
from langserve import RemoteRunnable, add_routes
app = FastAPI()
MAIN_SERVER_URL = (
"http://localhost:8123/chat_model/" # <-- URL of the RUNNABLE on the main server
)
# Type inference is not automatic for remote runnables at the moment,
# so you must specify which types are used for the playground to work.
remote_runnable = RemoteRunnable(MAIN_SERVER_URL).with_types(input_type=str)
# Let's add an example chain
add_routes(
app,
remote_runnable,
path="/proxied",
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app)
+20
View File
@@ -0,0 +1,20 @@
"""Main server that exposes one or more chains as HTTP endpoints."""
from fastapi import FastAPI
from langchain_openai import ChatOpenAI
from langserve import add_routes
app = FastAPI()
# Let's add an example chain
add_routes(
app,
ChatOpenAI(),
path="/chat_model",
)
if __name__ == "__main__":
import uvicorn
# Running on port 8123
uvicorn.run(app, port=8123)
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python
"""Example LangChain Server that uses a Fast API Router.
When applications grow, it becomes useful to use FastAPI's Router to organize
the routes.
See more documentation at:
https://fastapi.tiangolo.com/tutorial/bigger-applications/
"""
from fastapi import APIRouter, FastAPI
from langchain.chat_models import ChatAnthropic, ChatOpenAI
from langserve import add_routes
app = FastAPI()
router = APIRouter(prefix="/models")
# Invocations to this router will appear in trace logs as /models/openai
add_routes(
router,
ChatOpenAI(),
path="/openai",
)
# Invocations to this router will appear in trace logs as /models/anthropic
add_routes(
router,
ChatAnthropic(),
path="/anthropic",
)
app.include_router(router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="localhost", port=8000)
+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)
+23 -15
View File
@@ -171,7 +171,7 @@ async def _unpack_request_config(
def _update_config_with_defaults(
path: str,
run_name: str,
incoming_config: RunnableConfig,
request: Request,
*,
@@ -209,7 +209,7 @@ def _update_config_with_defaults(
metadata.update(hosted_metadata)
non_overridable_default_config = RunnableConfig(
run_name=path,
run_name=run_name,
metadata=metadata,
)
@@ -534,8 +534,16 @@ class APIHandler:
self._config_keys = config_keys
self._path = path
self._base_url = prefix + path
self._path = path.rstrip("/")
self._base_url = prefix + self._path
# Setting the run name explicitly
# the run name is set to the base url, which takes into account
# the prefix (e.g., if there's an APIRouter used) and the path relative
# to the router.
# If the base path is /foo/bar, the run name will be /foo/bar
# and when tracing information is logged, we'll be able to see
# traces for the path /foo/bar.
self._run_name = self._base_url
self._include_callback_events = include_callback_events
self._per_req_config_modifier = per_req_config_modifier
self._serializer = WellKnownLCSerializer()
@@ -663,7 +671,7 @@ class APIHandler:
server_config=server_config,
)
config = _update_config_with_defaults(
self._path,
self._run_name,
user_provided_config,
request,
endpoint=endpoint,
@@ -814,7 +822,7 @@ class APIHandler:
_add_callbacks(config_, [aggregator])
final_configs.append(
_update_config_with_defaults(
self._path, config_, request, endpoint="batch"
self._run_name, config_, request, endpoint="batch"
)
)
@@ -1236,7 +1244,7 @@ class APIHandler:
server_config=server_config,
)
config = _update_config_with_defaults(
self._path, user_provided_config, request
self._run_name, user_provided_config, request
)
return self._runnable.get_input_schema(config).schema()
@@ -1264,7 +1272,7 @@ class APIHandler:
server_config=server_config,
)
config = _update_config_with_defaults(
self._path, user_provided_config, request
self._run_name, user_provided_config, request
)
return self._runnable.get_output_schema(config).schema()
@@ -1291,7 +1299,7 @@ class APIHandler:
server_config=server_config,
)
config = _update_config_with_defaults(
self._path, user_provided_config, request
self._run_name, user_provided_config, request
)
return (
self._runnable.with_config(config)
@@ -1324,16 +1332,16 @@ class APIHandler:
)
config = _update_config_with_defaults(
self._path, user_provided_config, request
self._run_name, user_provided_config, request
)
playground_url = (
request.scope.get("root_path", "").rstrip("/")
+ self._base_url
+ "/playground"
)
feedback_enabled = tracing_is_enabled() and self._enable_feedback_endpoint
if self._base_url.endswith("/"):
playground_url = self._base_url + "playground"
else:
playground_url = self._base_url + "/playground"
return await serve_playground(
self._runnable.with_config(config),
self._runnable.with_config(config).input_schema,
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,
+1
View File
@@ -826,6 +826,7 @@ def add_routes(
include_in_schema=True,
tags=route_tags,
name=_route_name("stream"),
dependencies=dependencies,
description=(
"This endpoint allows to stream the output of the runnable. "
"The endpoint uses a server sent event stream to stream the "
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langserve"
version = "0.0.41"
version = "0.0.43"
description = ""
readme = "README.md"
authors = ["LangChain"]
+36
View File
@@ -307,6 +307,42 @@ async def test_serve_playground_with_api_router() -> None:
assert response.status_code == 200
async def test_root_path_on_playground(event_loop: AbstractEventLoop) -> None:
"""Test that the playground respects the root_path for requesting assets"""
for root_path in ("/home/root", "/home/root/"):
app = FastAPI(root_path=root_path)
add_routes(
app,
RunnableLambda(lambda foo: "hello"),
path="/chat",
)
router = APIRouter(prefix="/router")
add_routes(
router,
RunnableLambda(lambda foo: "hello"),
path="/chat",
)
app.include_router(router)
async_client = AsyncClient(app=app, base_url="http://localhost:9999")
response = await async_client.get("/chat/playground/index.html")
assert response.status_code == 200
assert (
f'src="{root_path.rstrip("/")}/chat/playground/assets/'
in response.content.decode()
), "html should contain reference to playground assets with root_path prefix"
response = await async_client.get("/router/chat/playground/index.html")
assert response.status_code == 200
assert (
f'src="{root_path.rstrip("/")}/router/chat/playground/assets/'
in response.content.decode()
), "html should contain reference to playground assets with root_path prefix"
async def test_server_async(app: FastAPI) -> None:
"""Test the server directly via HTTP requests."""
async with get_async_test_client(app, raise_app_exceptions=True) as async_client: