Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions browser/css/aichat-sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -203,15 +203,50 @@
opacity: 0.8;
}

#aichat-eu-wrapper {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
padding: 6px 8px 4px;
cursor: help;
}

#aichat-eu-notice {
color: var(--color-text-lighter);
text-align: center;
padding: 6px 8px 4px;
text-align: start;
font-size: 11px;
line-height: 1.4;
letter-spacing: 0.01em;
flex: 1;
margin: 0;
padding: 0;
}

/* -- Ethical AI rating badge -- */

#aichat-rating-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 14px;
height: 14px;
min-width: 14px;
border-radius: 50%;
font-size: 10px;
font-weight: 700;
color: transparent;
background: #888;
flex-shrink: 0;
line-height: 1;
}

#aichat-rating-badge[data-rating='A'] { background: #2a7a2a; }
#aichat-rating-badge[data-rating='B'] { background: #b56a00; }
#aichat-rating-badge[data-rating='C'] { background: #a33; }
#aichat-rating-badge[data-rating='U'] { background: #888; }

/* -- Hint (transient inline warning) -- */

#aichat-hint {
Expand Down
9 changes: 8 additions & 1 deletion browser/src/app/ServerConnectionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface ViewSetting {
aiConfigured?: boolean;
aiRequestTimeout?: string;
aiModelName?: string;
aiEthicalRating?: string;
}

class ServerConnectionService {
Expand All @@ -37,7 +38,11 @@ class ServerConnectionService {
app.tableStyles = new TableStylesService();
}

public onWopiProps(props: { AIConfigured: boolean; AIModelName: string }) {
public onWopiProps(props: {
AIConfigured: boolean;
AIModelName: string;
AIEthicalRating: string;
}) {
app.console.debug('ServerConnectionService: onWopiProps');

if (!app.map) {
Expand All @@ -47,6 +52,7 @@ class ServerConnectionService {

app.map.isAIConfigured = !!props.AIConfigured;
app.map.aiModelName = props.AIModelName || '';
app.map.aiEthicalRating = props.AIEthicalRating || 'U';
}

public onViewSetting(viewSetting: ViewSetting) {
Expand All @@ -62,6 +68,7 @@ class ServerConnectionService {
? Math.max(10, Number(viewSetting.aiRequestTimeout))
: 120;
app.map.aiModelName = viewSetting.aiModelName || '';
app.map.aiEthicalRating = viewSetting.aiEthicalRating || 'U';

let zoteroPlugin = app.map.zotero;
const zoteroAPIKey = viewSetting.zoteroAPIKey;
Expand Down
1 change: 1 addition & 0 deletions browser/src/app/iface/Map.Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ interface MapInterface extends Evented {
isAIConfigured?: boolean;
aiRequestTimeout?: number;
aiModelName?: string;
aiEthicalRating?: string;

_controlCorners: Record<string, Node>;
_contextMenu: ContextMenuControl;
Expand Down
48 changes: 44 additions & 4 deletions browser/src/control/Control.AIChatSidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,38 @@ namespace cool {
this.builder.build(this.container, [data], false);
this.applyMessageStyles();
this.applyInputStyles();
this.applyRatingBadge();
this.scrollToBottom();
this.attachContainerKeyboardHandler();
if (!this.isProcessing) {
this.focusInput();
}
}

private static readonly RATING_LABELS: Record<string, string> = {
A: 'Best',
B: 'Good',
C: 'Poor',
U: 'Unknown',
};

private applyRatingBadge(): void {
const badge = document.getElementById('aichat-rating-badge');
if (!badge) return;
const letter = app.map.aiEthicalRating || 'U';
badge.setAttribute('data-rating', letter);
const label = _(AIChatSidebar.RATING_LABELS[letter] || 'Unknown');
const aria = _(
'Ethical AI rating: {0} ({1}). Based on open-source licensing, self-hosting, and training data.',
)
.replace('{0}', letter)
.replace('{1}', label);
badge.setAttribute('role', 'img');
badge.setAttribute('aria-label', aria);
const wrapper = document.getElementById('aichat-eu-wrapper');
if (wrapper) wrapper.setAttribute('title', aria);
}

private updateMessagesArea(): void {
this.builder.updateWidget(this.container, this.getMessagesAreaJSON());
app.layoutingService.onDrain(() => {
Expand All @@ -186,6 +211,7 @@ namespace cool {
this.builder.updateWidget(this.container, this.getInputJSON());
app.layoutingService.onDrain(() => {
this.applyInputStyles();
this.applyRatingBadge();
if (!this.isProcessing) {
this.focusInput();
}
Expand Down Expand Up @@ -332,11 +358,25 @@ namespace cool {
: _(
'Content generated by an external AI service. Please review before use.',
);
const ratingLetter = app.map.aiEthicalRating || 'U';
return {
id: 'aichat-eu-notice',
type: 'fixedtext',
text: text,
enabled: true,
id: 'aichat-eu-wrapper',
type: 'container',
horizontal: true,
children: [
{
id: 'aichat-rating-badge',
type: 'fixedtext',
text: ratingLetter,
enabled: true,
},
{
id: 'aichat-eu-notice',
type: 'fixedtext',
text: text,
enabled: true,
},
],
};
}

Expand Down
2 changes: 2 additions & 0 deletions browser/src/map/handler/Map.WOPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,11 @@ window.L.Map.WOPI = window.L.Handler.extend({
this.DisableAISettings = !!wopiInfo['DisableAISettings'];
this.AIConfigured = !!wopiInfo['AIConfigured'];
this.AIModelName = wopiInfo['AIModelName'] || '';
this.AIEthicalRating = wopiInfo['AIEthicalRating'] || 'U';
app.serverConnectionService.onWopiProps({
AIConfigured: this.AIConfigured,
AIModelName: this.AIModelName,
AIEthicalRating: this.AIEthicalRating,
});
this.SupportsRename = !!wopiInfo['SupportsRename'];
this.UserCanRename = !!wopiInfo['UserCanRename'];
Expand Down
75 changes: 72 additions & 3 deletions wsd/ClientSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1613,10 +1613,76 @@ void ClientSession::uploadViewSettingsToWopiHost()
}
}

namespace
{

bool isProprietaryModel(const std::string& model)
{
if (model.empty())
return false;

if (model.starts_with("gpt-") && !model.starts_with("gpt-oss"))
return true;
// OpenAI o-series reasoning models
if (model.starts_with("o1") || model.starts_with("o3") || model.starts_with("o4"))
return true;
if (model.starts_with("claude-"))
return true;
if (model.starts_with("gemini-"))
return true;
return false;
}

bool isKnownCloudProvider(const std::string& url)
{
if (url.empty())
return false;

std::string host;
try
{
host = Poco::URI(url).getHost();
}
catch (const std::exception&)
{
return false;
}

// Convert to lowercase for comparison.
std::transform(host.begin(), host.end(), host.begin(),
[](unsigned char c) { return std::tolower(c); });

static const std::string cloudDomains[] = {
"api.openai.com", "api.anthropic.com", "generativelanguage.googleapis.com",
"api.mistral.ai", "openrouter.ai", "api.deepseek.com",
"api.together.xyz", "api.fireworks.ai",
};

for (const auto& domain : cloudDomains)
if (host == domain)
return true;

return false;
}

// Ethical AI rating: A (open+self-hosted), B (open+cloud), C (proprietary), U (unknown).
std::string computeEthicalRating(const std::string& model, const std::string& url)
{
if (model.empty())
return "U";

if (isProprietaryModel(model))
return "C";

return isKnownCloudProvider(url) ? "B" : "A";
}

} // anonymous namespace

bool ClientSession::resolveAndApplyAICredentials(Poco::JSON::Object::Ptr viewSettings,
const Poco::JSON::Object::Ptr& userPrivateInfoObj,
bool disableAISettings, bool& viewSettingsMutated,
std::string& outModel)
std::string& outModel, std::string& outRating)
{
auto resolveField = [&](const std::string& vsKey, const std::string& upiKey,
const std::string& cfgKey) -> std::string
Expand Down Expand Up @@ -1650,6 +1716,7 @@ bool ClientSession::resolveAndApplyAICredentials(Poco::JSON::Object::Ptr viewSet
const bool configured = ConfigUtil::getConfigValue<bool>("ai.enabled", false) &&
!disableAISettings && !apiKey.empty() && !model.empty() && !url.empty();
outModel = configured ? model : std::string{};
outRating = configured ? computeEthicalRating(model, url) : "U";
return configured;
}

Expand All @@ -1665,10 +1732,11 @@ bool ClientSession::handleUpdateViewSettings(const std::string& firstLine)
}

bool viewSettingsMutated = false;
std::string resolvedModel;
std::string resolvedModel, resolvedRating;
const bool aiConfigured =
resolveAndApplyAICredentials(viewSettings, /*userPrivateInfoObj=*/nullptr,
isDisableAISettings(), viewSettingsMutated, resolvedModel);
isDisableAISettings(), viewSettingsMutated,
resolvedModel, resolvedRating);

std::string aiImageProviderAPIKey, aiImageProviderURL, aiImageModel, aiImageSize;
std::string aiRequestTimeout;
Expand Down Expand Up @@ -1704,6 +1772,7 @@ bool ClientSession::handleUpdateViewSettings(const std::string& firstLine)
viewSettings->set("aiConfigured", aiConfigured);
if (aiConfigured)
viewSettings->set("aiModelName", resolvedModel);
viewSettings->set("aiEthicalRating", resolvedRating);

sendTextFrame("viewsetting: " + JsonUtil::jsonToString(viewSettings));

Expand Down
6 changes: 4 additions & 2 deletions wsd/ClientSession.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -341,14 +341,16 @@ class ClientSession final : public Session
/// and a field is filled from userPrivateInfoObj, viewSettings is mutated
/// (and viewSettingsMutated set to true) so callers can persist the
/// migration. outModel receives the resolved model name when aiConfigured,
/// otherwise an empty string.
/// otherwise an empty string. outRating receives the ethical AI rating
/// (A/B/C/U).
/// Returns aiConfigured: ai.enabled AND all three fields non-empty.
bool resolveAndApplyAICredentials(
Poco::JSON::Object::Ptr viewSettings,
const Poco::JSON::Object::Ptr& userPrivateInfoObj,
bool disableAISettings,
bool& viewSettingsMutated,
std::string& outModel);
std::string& outModel,
std::string& outRating);

/// Override parsedDocOption values we get from browser setting json
/// Because when client sends `load url` it doesn't have information about browser setting json
Expand Down
11 changes: 7 additions & 4 deletions wsd/DocumentBroker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1504,13 +1504,15 @@ DocumentBroker::updateSessionWithWopiInfo(const std::shared_ptr<ClientSession>&
if (!userPrivateInfo.empty())
JsonUtil::parseJSON(userPrivateInfo, userPrivateInfoObj);
bool unusedMutated = false;
std::string resolvedAIModel;
std::string resolvedAIModel, resolvedAIRating;
const bool aiConfigured = session->resolveAndApplyAICredentials(
/*viewSettings=*/nullptr, userPrivateInfoObj,
wopiFileInfo->getDisableAISettings(), unusedMutated, resolvedAIModel);
wopiFileInfo->getDisableAISettings(), unusedMutated,
resolvedAIModel, resolvedAIRating);
wopiInfo->set("AIConfigured", aiConfigured);
if (aiConfigured)
wopiInfo->set("AIModelName", resolvedAIModel);
wopiInfo->set("AIEthicalRating", resolvedAIRating);
wopiInfo->set("EnableShare", wopiFileInfo->getEnableShare());
wopiInfo->set("HideUserList", wopiFileInfo->getHideUserList());
wopiInfo->set("SupportsRename", wopiFileInfo->getSupportsRename());
Expand Down Expand Up @@ -1820,10 +1822,10 @@ static std::string extractViewSettings(const std::string& viewSettingsPath,

_isViewSettingsUpdated = true;

std::string resolvedAIModel;
std::string resolvedAIModel, resolvedAIRating;
const bool aiConfigured = session->resolveAndApplyAICredentials(
viewSettings, userPrivateInfoObj, session->isDisableAISettings(),
viewSettingsNeedUpdate, resolvedAIModel);
viewSettingsNeedUpdate, resolvedAIModel, resolvedAIRating);

JsonUtil::findJSONValue(viewSettings, "aiImageProviderAPIKey", aiImageProviderAPIKey);
JsonUtil::findJSONValue(viewSettings, "aiImageProviderURL", aiImageProviderURL);
Expand Down Expand Up @@ -1855,6 +1857,7 @@ static std::string extractViewSettings(const std::string& viewSettingsPath,
viewSettings->set("aiConfigured", aiConfigured);
if (aiConfigured)
viewSettings->set("aiModelName", resolvedAIModel);
viewSettings->set("aiEthicalRating", resolvedAIRating);
viewSettingsString = JsonUtil::jsonToString(viewSettings);
}
catch (const std::exception& exc)
Expand Down
Loading